diff --git a/.env b/.env index 65e82f8ee3..e50d90a292 100644 --- a/.env +++ b/.env @@ -158,7 +158,7 @@ SENTRY_RELEASE=local # REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index 13810cbb68..91bd64e962 100644 --- a/.github/actions/trivy-scan/action.yml +++ b/.github/actions/trivy-scan/action.yml @@ -64,7 +64,7 @@ runs: scanners: 'vuln' ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate timeout: '5m' - version: 'v0.72.0' + version: 'v0.73.0' # Not trivyignores: that input drops the .yaml extension Trivy parses by. env: TRIVY_IGNOREFILE: '.trivyignore.yaml' @@ -81,7 +81,7 @@ runs: scanners: 'vuln' ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate timeout: '5m' - version: 'v0.72.0' + version: 'v0.73.0' # Not trivyignores: that input drops the .yaml extension Trivy parses by. env: TRIVY_IGNOREFILE: '.trivyignore.yaml' diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index 4694c34e27..d822fad086 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -111,6 +111,7 @@ jobs: with: files: | api/** + .trivyignore.yaml .github/actions/trivy-scan/** .github/actions/grype-scan/** .grype.yaml diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index a2efee62ce..a5b8104b88 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -103,6 +103,7 @@ jobs: with: files: | mcp_server/** + .trivyignore.yaml .github/actions/trivy-scan/** .github/actions/grype-scan/** .grype.yaml diff --git a/.github/workflows/mcp-pypi-release.yml b/.github/workflows/mcp-pypi-release.yml index a6dae75017..f6ad43e39f 100644 --- a/.github/workflows/mcp-pypi-release.yml +++ b/.github/workflows/mcp-pypi-release.yml @@ -113,7 +113,7 @@ jobs: - name: Publish prowler-mcp package to PyPI if: steps.pypi-check.outputs.skip != 'true' - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/ print-hash: true diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 1ec8fbdfb3..cd940be409 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -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/** diff --git a/.github/workflows/sdk-pypi-release.yml b/.github/workflows/sdk-pypi-release.yml index a61c9157e8..90d869f789 100644 --- a/.github/workflows/sdk-pypi-release.yml +++ b/.github/workflows/sdk-pypi-release.yml @@ -85,7 +85,7 @@ jobs: run: uv build - name: Publish Prowler package to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: print-hash: true @@ -129,6 +129,6 @@ jobs: run: uv build - name: Publish prowler-cloud package to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: print-hash: true diff --git a/.github/workflows/ui-container-checks.yml b/.github/workflows/ui-container-checks.yml index 1b22b97a8f..8df5927529 100644 --- a/.github/workflows/ui-container-checks.yml +++ b/.github/workflows/ui-container-checks.yml @@ -104,6 +104,7 @@ jobs: with: files: | ui/** + .trivyignore.yaml .github/actions/trivy-scan/** .github/actions/grype-scan/** .grype.yaml diff --git a/.grype.yaml b/.grype.yaml index 0334555076..8fe74513c9 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -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 diff --git a/.trivyignore.yaml b/.trivyignore.yaml index ee42e4e7c6..3157018994 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -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.73.0, the latest published release and the version the + # images ship, 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 - diff --git a/Dockerfile b/Dockerfile index 62aa3c4f1e..8956851347 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,15 +8,15 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) ENV POWERSHELL_TELEMETRY_OPTOUT=1 -ARG TRIVY_VERSION=0.72.0 +ARG TRIVY_VERSION=0.73.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. -ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea -ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467 +ARG TRIVY_SHA256_AMD64=2edd39da482bb4e9831962487b68f68e3928ec3137794757f54d00383d79547b +ARG TRIVY_SHA256_ARM64=13833d97e8a1a5367471c372a173180157f593bece570e20d5d925fef552f5dd ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0 ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 diff --git a/README.md b/README.md index 0d0fd1fdfe..de5c235dd7 100644 --- a/README.md +++ b/README.md @@ -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] diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index b119fbaafc..1844873685 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to the **Prowler API** are documented in this file. +## [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 diff --git a/api/Dockerfile b/api/Dockerfile index af7b07e16c..61ecf7725d 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -7,15 +7,15 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) ENV POWERSHELL_TELEMETRY_OPTOUT=1 -ARG TRIVY_VERSION=0.72.0 +ARG TRIVY_VERSION=0.73.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. -ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea -ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467 +ARG TRIVY_SHA256_AMD64=2edd39da482bb4e9831962487b68f68e3928ec3137794757f54d00383d79547b +ARG TRIVY_SHA256_ARM64=13833d97e8a1a5367471c372a173180157f593bece570e20d5d925fef552f5dd ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0 ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 diff --git a/api/changelog.d/api-trivy-0730-bump.security.md b/api/changelog.d/api-trivy-0730-bump.security.md new file mode 100644 index 0000000000..a9ec43c86d --- /dev/null +++ b/api/changelog.d/api-trivy-0730-bump.security.md @@ -0,0 +1 @@ +Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` diff --git a/api/changelog.d/saml-acs-post-only.fixed.md b/api/changelog.d/saml-acs-post-only.fixed.md deleted file mode 100644 index 91f00e7d62..0000000000 --- a/api/changelog.d/saml-acs-post-only.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing diff --git a/api/changelog.d/tenant-deletion-transaction.fixed.md b/api/changelog.d/tenant-deletion-transaction.fixed.md deleted file mode 100644 index 5679bc4564..0000000000 --- a/api/changelog.d/tenant-deletion-transaction.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails diff --git a/api/changelog.d/users-me-active-membership.changed.md b/api/changelog.d/users-me-active-membership.changed.md deleted file mode 100644 index 4e02368ab4..0000000000 --- a/api/changelog.d/users-me-active-membership.changed.md +++ /dev/null @@ -1 +0,0 @@ -`GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication diff --git a/api/pyproject.toml b/api/pyproject.toml index 3be69d7c43..79389bc3a4 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -71,7 +71,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.40.0" +version = "1.41.0" # Shared ruff baseline (kept in sync with mcp_server/pyproject.toml). # target-version tracks this project's lowest supported Python. diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index 55ac440f59..6806289fdb 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -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: diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index dce066dbed..6cb1d28392 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.40.0 + version: 1.41.0 description: |- Prowler API specification. diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 7d8e06bb0e..3fd14afbf7 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -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): diff --git a/api/uv.lock b/api/uv.lock index 604e3bc35c..ab4887ded4 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4935,7 +4935,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.40.0" +version = "1.41.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 899122773f..64c323a980 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,117 @@ description: "Track new features, improvements, provider updates, and bug fixes rss: true --- + + ### 🤖 Lighthouse AI — Finding Skills + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Lighthouse AI now embeds a Skills menu on every finding, answering the questions an analyst actually asks. **Contextual Fix** produces the fix for the finding, **Triage Decision** judges whether it is real and closes it out when it is not, and **Systemic Scope** determines whether the problem is a one-off or everywhere. A free-form "Ask Lighthouse anything" prompt sits in the same menu, and each run shows its progress and offers follow-up actions such as creating a Jira issue or muting the finding. + + ![Lighthouse AI Skills menu on a finding resource](/images/changelog/v5.39.0-lighthouse-finding-skills.png) + + Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse). + + ### ☁️ Azure Management Group Onboarding + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Azure subscriptions no longer onboard one at a time. Choose "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have. + + ![Azure onboarding method selector with the Management Group option](/images/changelog/v5.39.0-azure-mg-selector.png) + + Read more in the [Azure Management Groups documentation](https://docs.prowler.com/user-guide/tutorials/prowler-cloud-azure-management-groups). + + ### ✅ Findings Triage — Verify MANUAL Findings as PASS + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Checks that require human judgment report `MANUAL` findings. For these findings, and only for them, the triage status selector now offers **Resolved**: choosing it asks for the required written evidence and verifies the finding as passing. The finding then reports an effective `PASS` while preserving the raw `MANUAL` scan result, across findings, finding groups, compliance reports, and scans, with the attestation's author, evidence, and validity always visible. Attestations expire automatically after 90 days, or as soon as a new scan reports a real failure, returning the finding to the review queue. + + ![Triage status selector offering Resolved on a MANUAL finding](/images/changelog/v5.39.0-manual-pass-selector.png) + + ![Manual Pass details showing evidence, author, and validity](/images/changelog/v5.39.0-manual-pass-details.png) + + Read more in the [Findings Triage documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage#verify-a-manual-finding-as-pass). + + ### ☁️ Prowler Cloud MCP — Organizations Management and Grouped Jira Dispatch + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + The hosted Prowler Cloud MCP server adds eight organization tools, so an agent can onboard and manage entire cloud organizations end to end: create the organization, discover its accounts, subscriptions, and projects, apply the selection, and manage the resulting providers. The tools cover AWS Organizations, GCP organizations, and Azure tenant root management groups, and they are available to Lighthouse AI. + + `prowler_send_findings_to_jira` also gains Cloud-only dispatch capabilities: select failed findings by check IDs against the latest completed scan, and send them in grouped mode, one Jira work item per check listing up to 50 affected resources, with per-group error reporting. + + Read more in the [Prowler MCP tools documentation](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools) and its [Jira operations reference](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools#jira-operations). + + ### 🕸️ Attack Paths — Grouped Graph with Outcome Destinations + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + The Attack Paths graph now reads from source to destination. Resources of the same class collapse into a single expandable node with a count, clicking reveals its members, and every path terminates in an explicit outcome node naming the destination impact: code execution, privilege escalation, public exposure, or resource inventory. The per-account hub node is gone, and the clicked resource stays highlighted while its findings are expanded. + + ![Attack Paths graph from the Internet to a public exposure outcome node](/images/changelog/v5.39.0-attack-paths-graph.png) + + 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). + + ### 📚 New Compliance Framework — CMMC 2.0 + + The Cybersecurity Maturity Model Certification (CMMC) is the certification the US Department of Defense requires from contractors and suppliers that handle federal contract data. Prowler now includes CMMC 2.0 as a universal framework with all 149 requirements defined by the CMMC Program rule (32 CFR Part 170), organized in its three levels: + + - **Level 1 (Foundational):** 15 requirements for the basic safeguarding of Federal Contract Information, from FAR 52.204-21. + - **Level 2 (Advanced):** 110 requirements from NIST SP 800-171 Rev 2, protecting Controlled Unclassified Information. + - **Level 3 (Expert):** 24 enhanced requirements from NIST SP 800-172 for the most sensitive programs. + + Requirements map to Prowler checks across AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud, and Microsoft 365, so one framework reports the compliance posture of the whole estate. + + Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance). + + ### 🔍 Checks + + #### Microsoft 365 + + Twenty new Entra ID checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0: + + - **Password protection:** custom banned password list, on-premises enforcement, and lockout threshold and duration. + - **Default user permissions:** security group and Microsoft 365 group creation restricted, and guest invitations limited to allowed domains. + - **Conditional Access:** high and medium sign-in risk blocked, authentication transfer blocked, untrusted locations blocked, trusted named locations defined, sign-in frequency enforced, and token protection enforced. + - **Sessions and authentication methods:** idle session timeout configured, email one-time passcodes disabled, and Microsoft Authenticator context shown. + - **PIM and access reviews:** approval required to activate the Global Administrator and Privileged Role Administrator roles, and access reviews configured for guest users and privileged roles. + + Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365). + + #### AWS + + Two new checks detect hardcoded secrets: + + - `batch_job_definition_no_secrets` scans Batch job definition environment variables and command parameters. Thanks to @praneetrajv! + - `awslambda_layer_no_secrets_in_content` scans Lambda layer package content. Thanks to @ganiganesh25! + + Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws). + + ### 🙌 External Contributors + + Thank you to our community contributors for this release! + + - @praneetrajv: AWS `batch_job_definition_no_secrets` check ([#12117](https://github.com/prowler-cloud/prowler/pull/12117)) + - @ganiganesh25: AWS `awslambda_layer_no_secrets_in_content` check ([#12233](https://github.com/prowler-cloud/prowler/pull/12233)) + - @andoniaf: GitHub `organization_repository_creation_limited` now reports low severity when repository creation is limited to private or internal visibility ([#12164](https://github.com/prowler-cloud/prowler/pull/12164)) + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.39.0) for the complete list of changes. + + ### 📌 Compliance Watchlist diff --git a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index 3be229bcf8..5fccdbf251 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -12,10 +12,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 | -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. ## Tool Naming Convention @@ -125,7 +125,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` + + +`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. + + + +`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. + ### Attack Paths Analysis @@ -168,6 +181,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. +### 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. + + +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. + + +- **`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. diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 0744fafc6d..1897d415af 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -129,8 +129,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.38.0" -PROWLER_API_VERSION="5.38.0" +PROWLER_UI_VERSION="5.39.0" +PROWLER_API_VERSION="5.39.0" ``` diff --git a/docs/images/changelog/v5.39.0-attack-paths-graph.png b/docs/images/changelog/v5.39.0-attack-paths-graph.png new file mode 100644 index 0000000000..68cd44278f Binary files /dev/null and b/docs/images/changelog/v5.39.0-attack-paths-graph.png differ diff --git a/docs/images/changelog/v5.39.0-azure-mg-selector.png b/docs/images/changelog/v5.39.0-azure-mg-selector.png new file mode 100644 index 0000000000..2badc72932 Binary files /dev/null and b/docs/images/changelog/v5.39.0-azure-mg-selector.png differ diff --git a/docs/images/changelog/v5.39.0-lighthouse-finding-skills.png b/docs/images/changelog/v5.39.0-lighthouse-finding-skills.png new file mode 100644 index 0000000000..06775297eb Binary files /dev/null and b/docs/images/changelog/v5.39.0-lighthouse-finding-skills.png differ diff --git a/docs/images/changelog/v5.39.0-manual-pass-details.png b/docs/images/changelog/v5.39.0-manual-pass-details.png new file mode 100644 index 0000000000..1914cc71e3 Binary files /dev/null and b/docs/images/changelog/v5.39.0-manual-pass-details.png differ diff --git a/docs/images/changelog/v5.39.0-manual-pass-selector.png b/docs/images/changelog/v5.39.0-manual-pass-selector.png new file mode 100644 index 0000000000..f14b05f877 Binary files /dev/null and b/docs/images/changelog/v5.39.0-manual-pass-selector.png differ diff --git a/docs/images/organizations/azure/azure-authentication-details.png b/docs/images/organizations/azure/azure-authentication-details.png new file mode 100644 index 0000000000..998b089e77 Binary files /dev/null and b/docs/images/organizations/azure/azure-authentication-details.png differ diff --git a/docs/images/organizations/azure/azure-blocked-subscription.png b/docs/images/organizations/azure/azure-blocked-subscription.png new file mode 100644 index 0000000000..2c90d1606c Binary files /dev/null and b/docs/images/organizations/azure/azure-blocked-subscription.png differ diff --git a/docs/images/organizations/azure/azure-delete-organization.png b/docs/images/organizations/azure/azure-delete-organization.png new file mode 100644 index 0000000000..bad166184e Binary files /dev/null and b/docs/images/organizations/azure/azure-delete-organization.png differ diff --git a/docs/images/organizations/azure/azure-discovery-timeout.png b/docs/images/organizations/azure/azure-discovery-timeout.png new file mode 100644 index 0000000000..54d37359e3 Binary files /dev/null and b/docs/images/organizations/azure/azure-discovery-timeout.png differ diff --git a/docs/images/organizations/azure/azure-gathering-subscriptions.png b/docs/images/organizations/azure/azure-gathering-subscriptions.png new file mode 100644 index 0000000000..82ff78f967 Binary files /dev/null and b/docs/images/organizations/azure/azure-gathering-subscriptions.png differ diff --git a/docs/images/organizations/azure/azure-inert-group.png b/docs/images/organizations/azure/azure-inert-group.png new file mode 100644 index 0000000000..0d59f73d37 Binary files /dev/null and b/docs/images/organizations/azure/azure-inert-group.png differ diff --git a/docs/images/organizations/azure/azure-launch-scan.png b/docs/images/organizations/azure/azure-launch-scan.png new file mode 100644 index 0000000000..5505e8cb7f Binary files /dev/null and b/docs/images/organizations/azure/azure-launch-scan.png differ diff --git a/docs/images/organizations/azure/azure-organization-details-form.png b/docs/images/organizations/azure/azure-organization-details-form.png new file mode 100644 index 0000000000..847960ce93 Binary files /dev/null and b/docs/images/organizations/azure/azure-organization-details-form.png differ diff --git a/docs/images/organizations/azure/azure-organization-row-actions.png b/docs/images/organizations/azure/azure-organization-row-actions.png new file mode 100644 index 0000000000..5a7e810abe Binary files /dev/null and b/docs/images/organizations/azure/azure-organization-row-actions.png differ diff --git a/docs/images/organizations/azure/azure-providers-grouping.png b/docs/images/organizations/azure/azure-providers-grouping.png new file mode 100644 index 0000000000..66edf82821 Binary files /dev/null and b/docs/images/organizations/azure/azure-providers-grouping.png differ diff --git a/docs/images/organizations/azure/azure-replace-credentials-apply.png b/docs/images/organizations/azure/azure-replace-credentials-apply.png new file mode 100644 index 0000000000..a3f7d50a78 Binary files /dev/null and b/docs/images/organizations/azure/azure-replace-credentials-apply.png differ diff --git a/docs/images/organizations/azure/azure-replace-credentials-setup.png b/docs/images/organizations/azure/azure-replace-credentials-setup.png new file mode 100644 index 0000000000..04a3ad1b3a Binary files /dev/null and b/docs/images/organizations/azure/azure-replace-credentials-setup.png differ diff --git a/docs/images/organizations/azure/azure-test-connections.png b/docs/images/organizations/azure/azure-test-connections.png new file mode 100644 index 0000000000..6ed284c6d7 Binary files /dev/null and b/docs/images/organizations/azure/azure-test-connections.png differ diff --git a/docs/images/organizations/azure/azure-tree-view-subscriptions.png b/docs/images/organizations/azure/azure-tree-view-subscriptions.png new file mode 100644 index 0000000000..2af3fb3762 Binary files /dev/null and b/docs/images/organizations/azure/azure-tree-view-subscriptions.png differ diff --git a/docs/images/organizations/azure/select-azure-management-groups-method.png b/docs/images/organizations/azure/select-azure-management-groups-method.png new file mode 100644 index 0000000000..c95405ffb0 Binary files /dev/null and b/docs/images/organizations/azure/select-azure-management-groups-method.png differ diff --git a/docs/images/organizations/azure/select-azure-provider.png b/docs/images/organizations/azure/select-azure-provider.png new file mode 100644 index 0000000000..54f390ab72 Binary files /dev/null and b/docs/images/organizations/azure/select-azure-provider.png differ diff --git a/docs/images/prowler-app/attack-paths/fullscreen-mode.png b/docs/images/prowler-app/attack-paths/fullscreen-mode.png index 190d973638..ff88c2b6de 100644 Binary files a/docs/images/prowler-app/attack-paths/fullscreen-mode.png and b/docs/images/prowler-app/attack-paths/fullscreen-mode.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-filtered.png b/docs/images/prowler-app/attack-paths/graph-filtered.png index 3eddf04473..59fff5b50f 100644 Binary files a/docs/images/prowler-app/attack-paths/graph-filtered.png and b/docs/images/prowler-app/attack-paths/graph-filtered.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-visualization-expanded.png b/docs/images/prowler-app/attack-paths/graph-visualization-expanded.png new file mode 100644 index 0000000000..a870df7519 Binary files /dev/null and b/docs/images/prowler-app/attack-paths/graph-visualization-expanded.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-visualization.png b/docs/images/prowler-app/attack-paths/graph-visualization.png index d7f2a747eb..af1371c0d8 100644 Binary files a/docs/images/prowler-app/attack-paths/graph-visualization.png and b/docs/images/prowler-app/attack-paths/graph-visualization.png differ diff --git a/docs/images/prowler-app/attack-paths/navigation.png b/docs/images/prowler-app/attack-paths/navigation.png index 0526e05e5c..560a2d10a1 100644 Binary files a/docs/images/prowler-app/attack-paths/navigation.png and b/docs/images/prowler-app/attack-paths/navigation.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png new file mode 100644 index 0000000000..1914cc71e3 Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png new file mode 100644 index 0000000000..d447478dbe Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png new file mode 100644 index 0000000000..f14b05f877 Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png differ diff --git a/docs/user-guide/providers/microsoft365/authentication.mdx b/docs/user-guide/providers/microsoft365/authentication.mdx index c735f80b3b..2e4306db15 100644 --- a/docs/user-guide/providers/microsoft365/authentication.mdx +++ b/docs/user-guide/providers/microsoft365/authentication.mdx @@ -40,10 +40,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. @@ -111,10 +113,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 diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index 1b1013e41a..651b13a455 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -15,26 +15,24 @@ Attack Paths analyzes relationships between cloud resources, permissions, and se By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources. - Attack Paths is currently available for **AWS** providers. Support for - additional providers is planned. + Attack Paths is currently available for **AWS** providers. Support for additional providers is planned. ## Prerequisites The following prerequisites are required for Attack Paths: -- **An AWS provider is configured** with valid credentials in Prowler Cloud. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws). +- **An AWS provider is configured** with valid credentials. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws). - **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required. ## How Attack Paths Scans Work -Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources. +Attack Paths scans are generated automatically when a security scan runs on an AWS provider. When a scan produces graph data, it maps relationships between IAM principals, policies, trust configurations, and other resources. A scan can complete without producing graph data. -Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready. +When graph data is ready, the scan appears in the Attack Paths scan table with a check in the **Graph** column and can be selected regardless of its current status. Scans without graph data remain visible but cannot be selected. If a new scan cycle starts after graph data is available, the previous cycle remains available while the new scan runs. - Since Prowler scans all configured providers every **24 hours** by default, - Attack Paths data stays up to date automatically. + Prowler Cloud and Prowler Private Cloud scan configured providers every **24 hours** by default, so Attack Paths data stays up to date automatically. ## Accessing Attack Paths @@ -65,7 +63,7 @@ The scans table displays all Attack Paths scans with the following columns: - **Graph:** Whether Attack Paths graph data is available for the scan. - **Duration:** Total scan time. -To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data. +To select a scan for analysis, click any row with a check in the **Graph** column. A row can remain selectable while a new scan cycle runs because Attack Paths keeps the graph from the previous completed cycle available. - Only scans with graph data can be selected. Disabled rows include a tooltip - that explains why the graph is not available yet. + Only scans with graph data can be selected. Disabled rows include a tooltip that explains why the graph is not available yet. ## Choosing a Query @@ -96,9 +93,7 @@ To choose a query, click the dropdown and select from the available options. Eac Once selected, a description panel appears below the dropdown with more context about the query. - In Prowler Cloud and Prowler Private Cloud, the query selector hides queries - confirmed empty for the selected scan, so only queries that return data remain - visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries). + In Prowler Cloud and Prowler Private Cloud, the query selector hides built-in queries confirmed empty for the selected scan. Built-in queries without a confirmed empty result and the **Custom openCypher query** remain visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries). ## Configuring Query Parameters @@ -119,7 +114,7 @@ For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key** ## Writing Custom openCypher Queries -In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue. +In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalog. To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query. @@ -191,11 +186,7 @@ Custom queries traverse the same Cartography graph the built-in queries use. Nod For the complete reference, including the graph model, list-typed and JSON-encoded properties, performance guidance, and openCypher compatibility rules, see [Attack Paths Queries](/developer-guide/attack-paths-queries) in the Developer Guide. - AI assistants connected through Prowler MCP Server can fetch the exact - Cartography schema for the active scan via the - `prowler_get_attack_paths_cartography_schema` tool. This guarantees that - generated queries match the schema version pinned by the running Prowler - release. + AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan via the `prowler_get_attack_paths_cartography_schema` tool. This guarantees that generated queries match the schema version pinned by the running Prowler release. ## Executing a Query @@ -220,12 +211,22 @@ If the query returns no results, an informational message appears. Common reason After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings. +### Grouped Graphs and Query Outcomes + + + +Prowler Cloud and Prowler Private Cloud group resources of the same class and graph level into expandable nodes. Built-in query graphs also end with a query outcome, which states the result that the path can lead to. + +Prowler Local Server keeps the flat graph view, including the provider root. Custom openCypher queries do not have a catalog outcome, so their graphs do not include an outcome node. + ### Node Types -- **Provider root nodes:** Represent the AWS account or provider root for the selected scan. -- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets. +- **Grouped resource nodes:** Represent multiple resources of the same class in Prowler Cloud and Prowler Private Cloud. A number in the upper-right corner shows how many resources the node contains. A red outline indicates that one or more resources in the group have findings. +- **Resource nodes:** Represent individual cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets. A class with one resource remains an individual node. - **Internet nodes:** Represent exposure from the public internet. - **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low. +- **Outcome nodes:** Mark the terminal result of a built-in query in Prowler Cloud and Prowler Private Cloud. The orange node displays outcomes such as **Code execution**, **Privilege escalation**, **Public exposure**, or **Resource inventory**. A dashed ring and the label **Latent outcome** indicate an inventory or another partial outcome. +- **Provider root nodes:** Represent the AWS account or provider root in the Prowler Local Server flat graph. ### Edge Types @@ -233,7 +234,7 @@ After a successful execution, the graph visualization renders below the query bu - **Finding edges:** Dashed relationships between resources and their associated findings. - **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding. -The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view. +The standard graph view includes a minimap and a legend below the canvas. The legend shows the visible node types, finding risk levels, node states, and edge types present in the current view. Prowler Local Server also displays the provider root in the legend. Attack Paths graph showing an expanded AWS Role group and remaining numbered resource groups + ### Showing Related Findings Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them. -The graph automatically fits the selected resource and its related findings when the findings are shown. +The selected resource is highlighted in green while its findings are visible. The graph automatically fits the selected resource and its related findings when the findings are shown. ### Focusing a Finding Path @@ -278,12 +294,12 @@ The toolbar in the top-right corner of the graph provides: - **Zoom in / Zoom out:** Adjust the zoom level - **Fit graph to view:** Reset the view to fit the visible graph -- **Export graph:** Download the current graph as a PNG file +- **Collapse all groups:** Close every expanded resource group. This control appears only when a group is expanded +- **Export graph:** Download the current graph, including its grouped or expanded state and outcome, as a PNG file - **Fullscreen:** Open the graph in a full-size modal - Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within - the graph area. + Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within the graph area. ## Viewing Finding Details diff --git a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx index 15233e85c6..2f4defe40d 100644 --- a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx +++ b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx @@ -41,12 +41,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. @@ -92,6 +92,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 + + + +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) + + + + Go to **Findings** and filter by status **Manual**. + + + Expand a Finding Group and click the current status in the **Triage** column of an individual finding. + + + Select **Resolved**. Prowler opens the triage note modal with a required **Manual pass evidence** field. + + + Describe how the control was verified, then click **Save**. The evidence supports up to 500 characters. + + + +![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: @@ -117,7 +150,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 diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index c884e6ff6b..ca2d848ea7 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -66,7 +66,7 @@ The Prowler wizard walks you through the entire flow: deploying both roles from AWS Organizations Console showing the Organization ID in the left sidebar -- **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. 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. | diff --git a/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx b/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx index 98cfe095ce..f3dc55d709 100644 --- a/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx @@ -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. + -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. + + +For Command-Line Interface (CLI) scanning of several subscriptions, see [Configuring Specific Subscription Scans in Prowler](/user-guide/providers/azure/subscriptions). + + +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. + + +**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. + + + +**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. + + +## 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= +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" +``` + + +The tenant root Management Group is named after the tenant ID, so its resource identifier is always `/providers/Microsoft.Management/managementGroups/`. 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. + + +### 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/`, 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**. + + + Providers page showing the Add Provider button + + +2. Select **Microsoft Azure** as the provider. + + + Provider selection modal with Microsoft Azure highlighted + + +3. Choose **Add Multiple Subscriptions With Azure Management Group**. + + + Method selector showing the Add Multiple Subscriptions With Azure Management Group option highlighted + + + +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. + + +### 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. + + + Organization Details form with the Microsoft Entra tenant ID and Name fields + + +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. + + + Authentication Details step showing the Client ID and Client Secret fields + + +The tenant is not repeated here: Prowler takes it from the organization created in the previous step. + + +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. + + +### 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. + + + Gathering Azure Subscriptions spinner shown while discovery runs + + +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. + + + Discovery timeout notice offering Keep waiting and Retry + + +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: + + + Hierarchical tree view showing Management Groups and subscriptions with selection checkboxes + + +- **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. | + + + Blocked subscription row with the reason shown in a tooltip + + +### 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. + + + Disabled Management Group row noting that no subscriptions are available to select + + +### 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: + + + Replace existing credentials modal listing the subscriptions whose credentials will be replaced + + +Click **Replace and continue** to proceed, or **Cancel** to adjust your selection. + + +**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. + + +## 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. + + + Connection testing in progress with status icons on each subscription + + +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. + + + Launch Scan step showing the Subscriptions Connected confirmation and the scan schedule selector + + +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. + + + Providers page showing subscriptions grouped under Azure Management Groups and the organization + + +## Manage Your Organization After Onboarding + +Open the row actions menu on the organization row on the **Providers** page. + + + Row actions menu on an Azure organization row + + +| 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: + + + Replace existing credentials modal showing how many providers re-authenticate + + +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. + + + Delete organization dialog showing how many providers are deleted with it + + +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. + + +Deleting an organization **permanently deletes every subscription provider grouped under it**, including their historical scans and findings. This action cannot be undone. + + +### 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 --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 \ + --scope "/providers/Microsoft.Management/managementGroups/" \ + --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 + + + + Full guide to using Prowler Cloud features. + + + CLI-based scanning of specific Azure subscriptions. + + + Credential types and the permissions Prowler needs in Azure. + + + Script-based bulk provisioning for advanced automation. + + diff --git a/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx index b7cef38c62..f2943f39a7 100644 --- a/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx @@ -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. 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. | diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index 13aff68168..f4f4de040d 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -42,6 +42,7 @@ "lightsail:GetRelationalDatabases", "macie2:GetMacieSession", "macie2:GetAutomatedDiscoveryConfiguration", + "rolesanywhere:ListProfiles", "rolesanywhere:ListTagsForResource", "rolesanywhere:ListTrustAnchors", "s3:GetAccountPublicAccessBlock", diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml index c9eee71950..6cabb30664 100644 --- a/permissions/templates/cloudformation/prowler-scan-role.yml +++ b/permissions/templates/cloudformation/prowler-scan-role.yml @@ -213,6 +213,7 @@ Resources: - "lightsail:GetRelationalDatabases" - "macie2:GetMacieSession" - "macie2:GetAutomatedDiscoveryConfiguration" + - "rolesanywhere:ListProfiles" - "rolesanywhere:ListTagsForResource" - "rolesanywhere:ListTrustAnchors" - "s3:GetAccountPublicAccessBlock" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index c019e8d087..f2c33f123e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [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 diff --git a/prowler/changelog.d/awslambda-layer-no-secrets-in-content.added.md b/prowler/changelog.d/awslambda-layer-no-secrets-in-content.added.md deleted file mode 100644 index 56a192b088..0000000000 --- a/prowler/changelog.d/awslambda-layer-no-secrets-in-content.added.md +++ /dev/null @@ -1 +0,0 @@ -`awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets diff --git a/prowler/changelog.d/batch-job-definition-no-secrets.added.md b/prowler/changelog.d/batch-job-definition-no-secrets.added.md deleted file mode 100644 index 797dc06a12..0000000000 --- a/prowler/changelog.d/batch-job-definition-no-secrets.added.md +++ /dev/null @@ -1 +0,0 @@ -`batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets diff --git a/prowler/changelog.d/gcp-wif-provider-attribute-condition.added.md b/prowler/changelog.d/gcp-wif-provider-attribute-condition.added.md new file mode 100644 index 0000000000..4ab15242e2 --- /dev/null +++ b/prowler/changelog.d/gcp-wif-provider-attribute-condition.added.md @@ -0,0 +1 @@ +Add the `iam_workload_identity_pool_provider_attribute_condition` check to flag GCP Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals diff --git a/prowler/changelog.d/m365-cis7-entra-directory-settings.added.md b/prowler/changelog.d/m365-cis7-entra-directory-settings.added.md deleted file mode 100644 index 4fc76e535f..0000000000 --- a/prowler/changelog.d/m365-cis7-entra-directory-settings.added.md +++ /dev/null @@ -1 +0,0 @@ -7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions diff --git a/prowler/changelog.d/rolesanywhere-profile-session-scoping.added.md b/prowler/changelog.d/rolesanywhere-profile-session-scoping.added.md new file mode 100644 index 0000000000..6f50731aae --- /dev/null +++ b/prowler/changelog.d/rolesanywhere-profile-session-scoping.added.md @@ -0,0 +1 @@ +Add the `rolesanywhere_profile_restricts_session_permissions` check to flag AWS IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies diff --git a/prowler/changelog.d/trivy-0730-bump.security.md b/prowler/changelog.d/trivy-0730-bump.security.md new file mode 100644 index 0000000000..a9ec43c86d --- /dev/null +++ b/prowler/changelog.d/trivy-0730-bump.security.md @@ -0,0 +1 @@ +Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` diff --git a/prowler/compliance/cmmc_2.0.json b/prowler/compliance/cmmc_2.0.json new file mode 100644 index 0000000000..7c5783af13 --- /dev/null +++ b/prowler/compliance/cmmc_2.0.json @@ -0,0 +1,4905 @@ +{ + "framework": "CMMC", + "name": "Cybersecurity Maturity Model Certification (CMMC) 2.0", + "version": "2.0", + "description": "The Cybersecurity Maturity Model Certification (CMMC) 2.0 program, codified in 32 CFR Part 170, is the U.S. Department of Defense mechanism to verify that defense contractors protect Federal Contract Information (FCI) and Controlled Unclassified Information (CUI). It defines three levels: Level 1 (15 basic safeguarding requirements from 48 CFR 52.204-21), Level 2 (the 110 security requirements of NIST SP 800-171 Rev 2) and Level 3 (24 enhanced requirements selected from NIST SP 800-172).", + "icon": "cmmc", + "attributes_metadata": [ + { + "key": "Domain", + "label": "Domain", + "type": "str", + "required": true, + "enum": [ + "Access Control", + "Audit and Accountability", + "Awareness and Training", + "Configuration Management", + "Identification and Authentication", + "Incident Response", + "Maintenance", + "Media Protection", + "Personnel Security", + "Physical Protection", + "Risk Assessment", + "Security Assessment", + "System and Communications Protection", + "System and Information Integrity" + ] + }, + { + "key": "Level", + "label": "CMMC Level", + "type": "str", + "required": true, + "enum": [ + "Level 1", + "Level 2", + "Level 3" + ] + }, + { + "key": "SourceRequirement", + "label": "Source Requirement", + "type": "str", + "required": true + } + ], + "outputs": { + "table_config": { + "group_by": "Domain", + "split_by": { + "field": "Level", + "values": [ + "Level 1", + "Level 2", + "Level 3" + ] + } + } + }, + "requirements": [ + { + "id": "AC.L1-b.1.i", + "description": "Limit information system access to authorized users, processes acting on behalf of authorized users, or devices (including other information systems).", + "attributes": { + "Domain": "Access Control", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(i)" + }, + "checks": { + "aws": [ + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "ec2_ebs_public_snapshot", + "ec2_instance_profile_attached", + "ec2_instance_public_ip", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic", + "eks_cluster_not_publicly_accessible", + "emr_cluster_master_nodes_no_public_ip", + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", + "iam_user_accesskey_unused", + "iam_user_console_access_unused", + "iam_user_mfa_enabled_console_access", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_public_network_access_disabled", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "keyvault_access_only_through_private_endpoints", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "postgresql_flexible_server_allow_access_services_disabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_ip", + "cloudstorage_bucket_public_access", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_image_not_publicly_shared", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_instance_public_ip", + "compute_project_os_login_enabled", + "kms_key_not_publicly_accessible", + "secretmanager_secret_not_publicly_accessible" + ], + "alibabacloud": [ + "cs_kubernetes_private_cluster_enabled", + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet", + "oss_bucket_not_publicly_accessible", + "ram_no_root_access_key", + "ram_user_mfa_enabled_console_access", + "rds_instance_no_public_access_whitelist" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "identity_tenancy_admin_users_no_api_keys", + "identity_user_mfa_enabled_console_access", + "integration_instance_access_restricted", + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "objectstorage_bucket_not_publicly_accessible" + ], + "m365": [ + "entra_legacy_authentication_blocked", + "entra_managed_device_required_for_authentication", + "exchange_shared_mailbox_sign_in_disabled" + ] + }, + "config_requirements": [ + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + }, + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + } + ] + }, + { + "id": "AC.L1-b.1.ii", + "description": "Limit information system access to the types of transactions and functions that authorized users are permitted to execute.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(ii)" + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker" + ], + "azure": [ + "aks_cluster_rbac_enabled", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_users_cannot_create_microsoft_365_groups", + "iam_role_user_access_admin_restricted", + "keyvault_rbac_enabled" + ], + "gcp": [ + "iam_no_service_roles_at_project_level" + ], + "alibabacloud": [ + "cs_kubernetes_rbac_enabled", + "ram_policy_attached_only_to_group_or_roles", + "ram_policy_no_administrative_privileges" + ], + "oraclecloud": [ + "identity_service_level_admins_exist", + "identity_storage_service_level_admins_scoped", + "identity_tenancy_admin_permissions_limited" + ], + "m365": [ + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_restricts_user_consent_for_apps", + "entra_thirdparty_integrated_apps_not_allowed" + ] + }, + "config_requirements": [ + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + } + ] + }, + { + "id": "AC.L1-b.1.iii", + "description": "Verify and control/limit connections to and use of external information systems.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(iii)" + }, + "checks": { + "aws": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic", + "s3_account_level_public_access_blocks" + ], + "azure": [ + "cosmosdb_account_firewall_use_selected_networks", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "sqlserver_unrestricted_inbound_access", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudsql_instance_public_access", + "cloudstorage_bucket_public_access", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "iam_account_access_approval_enabled" + ], + "alibabacloud": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet", + "oss_bucket_not_publicly_accessible", + "rds_instance_no_public_access_whitelist" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "objectstorage_bucket_not_publicly_accessible" + ], + "m365": [ + "entra_conditional_access_policy_mfa_enforced_for_guest_users", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "sharepoint_external_sharing_restricted", + "sharepoint_guest_sharing_restricted", + "teams_external_access_trial_tenants_blocked", + "teams_external_domains_restricted", + "teams_external_users_cannot_start_conversations", + "teams_unmanaged_communication_disabled" + ] + } + }, + { + "id": "AC.L1-b.1.iv", + "description": "Control information posted or processed on publicly accessible information systems.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(iv)" + }, + "checks": { + "aws": [ + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "ec2_ebs_public_snapshot", + "ec2_instance_public_ip", + "eks_cluster_not_publicly_accessible", + "emr_cluster_master_nodes_no_public_ip", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_public_network_access_disabled", + "postgresql_flexible_server_allow_access_services_disabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_ip", + "cloudstorage_bucket_public_access", + "compute_image_not_publicly_shared", + "compute_instance_public_ip", + "kms_key_not_publicly_accessible", + "secretmanager_secret_not_publicly_accessible" + ], + "alibabacloud": [ + "cs_kubernetes_private_cluster_enabled", + "oss_bucket_not_publicly_accessible", + "rds_instance_no_public_access_whitelist" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted", + "objectstorage_bucket_not_publicly_accessible" + ], + "m365": [ + "admincenter_groups_not_public_visibility", + "sharepoint_external_sharing_restricted", + "teams_meeting_anonymous_user_join_disabled", + "teams_meeting_anonymous_user_start_disabled", + "teams_meeting_chat_anonymous_users_disabled", + "teams_meeting_external_lobby_bypass_disabled" + ] + } + }, + { + "id": "IA.L1-b.1.v", + "description": "Identify information system users, processes acting on behalf of users, or devices.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(v)" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IA.L1-b.1.vi", + "description": "Authenticate (or verify) the identities of those users, processes, or devices, as a prerequisite to allowing access to organizational information systems.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(vi)" + }, + "checks": { + "aws": [ + "iam_root_mfa_enabled", + "iam_user_mfa_enabled_console_access" + ], + "azure": [ + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled" + ], + "gcp": [ + "compute_project_os_login_enabled" + ], + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "oraclecloud": [ + "identity_instance_principal_used", + "identity_user_mfa_enabled_console_access" + ], + "m365": [ + "entra_admin_users_mfa_enabled", + "entra_users_mfa_enabled" + ] + } + }, + { + "id": "MP.L1-b.1.vii", + "description": "Sanitize or destroy information system media containing Federal Contract Information before disposal or release for reuse.", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(vii)" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PE.L1-b.1.viii", + "description": "Limit physical access to organizational information systems, equipment, and the respective operating environments to authorized individuals.", + "attributes": { + "Domain": "Physical Protection", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(viii)" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PE.L1-b.1.ix", + "description": "Escort visitors and monitor visitor activity; maintain audit logs of physical access; and control and manage physical access devices.", + "attributes": { + "Domain": "Physical Protection", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(ix)" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L1-b.1.x", + "description": "Monitor, control, and protect organizational communications (i.e., information transmitted or received by organizational information systems) at the external boundaries and key internal boundaries of the information systems.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(x)" + }, + "checks": { + "aws": [ + "acm_certificates_expiration_check", + "apigateway_restapi_logging_enabled", + "awslambda_function_not_publicly_accessible", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "elb_logging_enabled", + "elb_ssl_listeners", + "elbv2_logging_enabled", + "elbv2_waf_acl_attached", + "guardduty_is_enabled", + "rds_instance_integration_cloudwatch_logs", + "rds_instance_no_public_access", + "redshift_cluster_public_access", + "s3_bucket_secure_transport_policy", + "s3_bucket_server_access_logging_enabled", + "securityhub_enabled", + "vpc_flow_logs_enabled" + ], + "azure": [ + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", + "network_udp_internet_access_restricted" + ], + "gcp": [ + "cloudstorage_uses_vpc_service_controls", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_subnet_flow_logs_enabled" + ], + "alibabacloud": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet", + "oss_bucket_secure_transport_enabled", + "rds_instance_no_public_access_whitelist", + "rds_instance_ssl_enabled", + "sls_security_group_changes_alert_enabled", + "vpc_flow_logs_enabled" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "network_vcn_subnet_flow_logs_enabled" + ], + "m365": [ + "defender_antispam_connection_filter_policy_empty_ip_allowlist", + "defender_antispam_connection_filter_policy_safe_list_off", + "exchange_application_access_policy_restricts_mailbox_apps", + "sharepoint_external_sharing_managed", + "sharepoint_external_sharing_restricted", + "teams_external_domains_restricted", + "teams_external_users_cannot_start_conversations", + "teams_unmanaged_communication_disabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + } + ] + }, + { + "id": "SC.L1-b.1.xi", + "description": "Implement subnetworks for publicly accessible system components that are physically or logically separated from internal networks.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(xi)" + }, + "checks": { + "aws": [ + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "ec2_confidential_workload_host_public_ip", + "ec2_ebs_public_snapshot", + "ec2_instance_public_ip", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic", + "elb_ssl_listeners", + "elbv2_waf_acl_attached", + "emr_cluster_master_nodes_no_public_ip", + "opensearch_service_domains_node_to_node_encryption_enabled", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "s3_bucket_secure_transport_policy", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_created_with_private_nodes", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_public_network_access_disabled", + "cosmosdb_account_use_private_endpoints", + "databricks_workspace_no_public_ip_enabled", + "databricks_workspace_public_network_access_disabled", + "databricks_workspace_vnet_injection_enabled", + "keyvault_access_only_through_private_endpoints", + "keyvault_private_endpoints", + "network_subnet_nsg_associated", + "postgresql_flexible_server_allow_access_services_disabled", + "storage_account_public_network_access_disabled", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "gcp": [ + "cloudfunction_function_inside_vpc", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_instance_public_ip", + "compute_network_default_in_use", + "compute_network_not_legacy" + ], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SI.L1-b.1.xii", + "description": "Identify, report, and correct information and information system flaws in a timely manner.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(xii)" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "securityhub_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_ensure_system_updates_are_applied" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied", + "securitycenter_vulnerability_scan_enabled" + ], + "oraclecloud": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "SI.L1-b.1.xiii", + "description": "Provide protection from malicious code at appropriate locations within organizational information systems.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(xiii)" + }, + "checks": { + "aws": [ + "guardduty_ec2_malware_protection_enabled", + "guardduty_s3_protection_enabled" + ], + "azure": [ + "defender_assessments_vm_endpoint_protection_installed" + ], + "gcp": [], + "alibabacloud": [ + "ecs_instance_endpoint_protection_installed" + ], + "oraclecloud": [], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "defender_safe_attachments_policy_enabled", + "defender_strict_preset_security_policy_enabled", + "defender_zap_for_teams_enabled" + ] + }, + "config_requirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ], + "Provider": "m365" + } + ] + }, + { + "id": "SI.L1-b.1.xiv", + "description": "Update malicious code protection mechanisms when new releases are available.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(xiv)" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SI.L1-b.1.xv", + "description": "Perform periodic scans of the information system and real-time scans of files from external sources as files are downloaded, opened, or executed.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 1", + "SourceRequirement": "48 CFR 52.204-21(b)(1)(xv)" + }, + "checks": { + "aws": [ + "guardduty_ec2_malware_protection_enabled" + ], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_safe_attachments_policy_enabled", + "defender_zap_for_teams_enabled" + ] + } + }, + { + "id": "AC.L2-3.1.1", + "description": "Limit system access to authorized users, processes acting on behalf of authorized users, and devices (including other systems).", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.1" + }, + "checks": { + "aws": [ + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "ec2_ebs_public_snapshot", + "ec2_instance_profile_attached", + "ec2_instance_public_ip", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic", + "eks_cluster_not_publicly_accessible", + "emr_cluster_master_nodes_no_public_ip", + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", + "iam_user_accesskey_unused", + "iam_user_console_access_unused", + "iam_user_mfa_enabled_console_access", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_public_network_access_disabled", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "keyvault_access_only_through_private_endpoints", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "postgresql_flexible_server_allow_access_services_disabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_ip", + "cloudstorage_bucket_public_access", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_image_not_publicly_shared", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_instance_public_ip", + "compute_project_os_login_enabled", + "kms_key_not_publicly_accessible", + "secretmanager_secret_not_publicly_accessible" + ], + "alibabacloud": [ + "cs_kubernetes_private_cluster_enabled", + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet", + "oss_bucket_not_publicly_accessible", + "ram_no_root_access_key", + "ram_user_mfa_enabled_console_access", + "rds_instance_no_public_access_whitelist" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "identity_tenancy_admin_users_no_api_keys", + "identity_user_mfa_enabled_console_access", + "integration_instance_access_restricted", + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "objectstorage_bucket_not_publicly_accessible" + ], + "m365": [ + "entra_legacy_authentication_blocked", + "entra_managed_device_required_for_authentication", + "exchange_shared_mailbox_sign_in_disabled" + ] + }, + "config_requirements": [ + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + }, + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + } + ] + }, + { + "id": "AC.L2-3.1.2", + "description": "Limit system access to the types of transactions and functions that authorized users are permitted to execute.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.2" + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker" + ], + "azure": [ + "aks_cluster_rbac_enabled", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_users_cannot_create_microsoft_365_groups", + "iam_role_user_access_admin_restricted", + "keyvault_rbac_enabled" + ], + "gcp": [ + "iam_no_service_roles_at_project_level" + ], + "alibabacloud": [ + "cs_kubernetes_rbac_enabled", + "ram_policy_attached_only_to_group_or_roles", + "ram_policy_no_administrative_privileges" + ], + "oraclecloud": [ + "identity_service_level_admins_exist", + "identity_storage_service_level_admins_scoped", + "identity_tenancy_admin_permissions_limited" + ], + "m365": [ + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_restricts_user_consent_for_apps", + "entra_thirdparty_integrated_apps_not_allowed" + ] + }, + "config_requirements": [ + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + } + ] + }, + { + "id": "AC.L2-3.1.3", + "description": "Control the flow of CUI in accordance with approved authorizations.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.3" + }, + "checks": { + "aws": [ + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "ec2_ebs_public_snapshot", + "ec2_instance_public_ip", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic", + "eks_cluster_not_publicly_accessible", + "emr_cluster_master_nodes_no_public_ip", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ], + "azure": [ + "cosmosdb_account_firewall_use_selected_networks", + "postgresql_flexible_server_allow_access_services_disabled", + "sqlserver_unrestricted_inbound_access", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "cloudsql_instance_public_access", + "cloudstorage_uses_vpc_service_controls" + ], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "exchange_transport_rules_mail_forwarding_disabled", + "exchange_transport_rules_whitelist_disabled", + "sharepoint_external_sharing_managed", + "teams_external_file_sharing_restricted" + ] + }, + "config_requirements": [ + { + "Check": "teams_external_file_sharing_restricted", + "ConfigKey": "allowed_cloud_storage_services", + "Operator": "subset", + "Value": [], + "Provider": "m365" + } + ] + }, + { + "id": "AC.L2-3.1.4", + "description": "Separate the duties of individuals to reduce the risk of malevolent activity without collusion.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [ + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties" + ], + "alibabacloud": [], + "oraclecloud": [ + "identity_iam_admins_cannot_update_tenancy_admins" + ], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.5", + "description": "Employ the principle of least privilege, including for specific security functions and privileged accounts.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.5" + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_no_root_access_key", + "iam_policy_no_wildcard_marketplace_subscribe", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "vm_jit_access_enabled" + ], + "gcp": [ + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account", + "iam_sa_no_administrative_privileges" + ], + "alibabacloud": [ + "ram_no_root_access_key", + "ram_policy_no_administrative_privileges", + "ram_user_console_access_unused" + ], + "oraclecloud": [ + "identity_service_level_admins_exist", + "identity_tenancy_admin_permissions_limited" + ], + "m365": [ + "entra_conditional_access_policy_groups_management_restricted", + "entra_service_principal_privileged_role_no_owners" + ] + }, + "config_requirements": [ + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + }, + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "ram_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "alibabacloud" + } + ] + }, + { + "id": "AC.L2-3.1.6", + "description": "Use non-privileged accounts or roles when accessing nonsecurity functions", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.6" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.7", + "description": "Prevent non-privileged users from executing privileged functions and capture the execution of such functions in audit logs.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.7" + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_no_root_access_key" + ], + "azure": [ + "monitor_diagnostic_setting_with_appropriate_categories" + ], + "gcp": [ + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "ram_policy_no_administrative_privileges", + "sls_root_account_usage_alert_enabled" + ], + "oraclecloud": [ + "identity_service_level_admins_exist", + "identity_tenancy_admin_permissions_limited" + ], + "m365": [ + "entra_admin_portals_access_restriction", + "purview_audit_log_search_enabled" + ] + } + }, + { + "id": "AC.L2-3.1.8", + "description": "Limit unsuccessful logon attempts.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.8" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [ + "ram_password_policy_max_login_attempts" + ], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.9", + "description": "Provide privacy and security notices consistent with applicable CUI rules.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.9" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.10", + "description": "Use session lock with pattern-hiding displays to prevent access and viewing of data after a period of inactivity", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.10" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.11", + "description": "Terminate (automatically) a user session after a defined condition.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.11" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "entra_admin_users_sign_in_frequency_enabled" + ] + }, + "config_requirements": [ + { + "Check": "entra_admin_users_sign_in_frequency_enabled", + "ConfigKey": "sign_in_frequency", + "Operator": "lte", + "Value": 4, + "Provider": "m365" + } + ] + }, + { + "id": "AC.L2-3.1.12", + "description": "Monitor and control remote access sessions.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.12" + }, + "checks": { + "aws": [ + "apigateway_restapi_logging_enabled", + "cloudtrail_multi_region_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "guardduty_is_enabled", + "rds_instance_integration_cloudwatch_logs", + "s3_bucket_server_access_logging_enabled", + "securityhub_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "vm_jit_access_enabled" + ], + "gcp": [ + "compute_subnet_flow_logs_enabled" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "rds_instance_postgresql_log_connections_enabled", + "rds_instance_postgresql_log_disconnections_enabled", + "rds_instance_sql_audit_enabled", + "rds_instance_sql_audit_retention", + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "vpc_flow_logs_enabled" + ], + "oraclecloud": [], + "m365": [ + "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "rds_instance_sql_audit_retention", + "ConfigKey": "min_rds_audit_retention_days", + "Operator": "gte", + "Value": 180, + "Provider": "alibabacloud" + } + ] + }, + { + "id": "AC.L2-3.1.13", + "description": "Employ cryptographic mechanisms to protect the confidentiality of remote access sessions.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.13" + }, + "checks": { + "aws": [ + "elb_ssl_listeners" + ], + "azure": [], + "gcp": [], + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.14", + "description": "Route remote access via managed access control points.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.14" + }, + "checks": { + "aws": [ + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "ec2_ebs_public_snapshot", + "ec2_instance_public_ip", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic", + "iam_user_mfa_enabled_console_access", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ], + "azure": [ + "network_bastion_host_exists", + "network_subnet_nsg_associated" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_project_os_login_enabled" + ], + "alibabacloud": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port" + ], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.15", + "description": "Authorize remote execution of privileged commands and remote access to security-relevant information.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.15" + }, + "checks": { + "aws": [], + "azure": [ + "vm_jit_access_enabled" + ], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.16", + "description": "Authorize wireless access prior to allowing such connections", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.16" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.17", + "description": "Protect wireless access using authentication and encryption", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.17" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.18", + "description": "Control connection of mobile devices.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.18" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "entra_conditional_access_policy_approved_client_app_required_for_mobile", + "entra_conditional_access_policy_mdm_compliant_device_required", + "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + ] + } + }, + { + "id": "AC.L2-3.1.19", + "description": "Encrypt CUI on mobile devices and mobile computing platforms.[23]", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.19" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.20", + "description": "Verify and control/limit connections to and use of external systems.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.20" + }, + "checks": { + "aws": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic", + "s3_account_level_public_access_blocks" + ], + "azure": [ + "cosmosdb_account_firewall_use_selected_networks", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "sqlserver_unrestricted_inbound_access", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudsql_instance_public_access", + "cloudstorage_bucket_public_access", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "iam_account_access_approval_enabled" + ], + "alibabacloud": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet", + "oss_bucket_not_publicly_accessible", + "rds_instance_no_public_access_whitelist" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "objectstorage_bucket_not_publicly_accessible" + ], + "m365": [ + "entra_conditional_access_policy_mfa_enforced_for_guest_users", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "sharepoint_external_sharing_restricted", + "sharepoint_guest_sharing_restricted", + "teams_external_access_trial_tenants_blocked", + "teams_external_domains_restricted", + "teams_external_users_cannot_start_conversations", + "teams_unmanaged_communication_disabled" + ] + } + }, + { + "id": "AC.L2-3.1.21", + "description": "Limit use of portable storage devices on external systems.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.21" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L2-3.1.22", + "description": "Control CUI posted or processed on publicly accessible systems.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.1.22" + }, + "checks": { + "aws": [ + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "ec2_ebs_public_snapshot", + "ec2_instance_public_ip", + "eks_cluster_not_publicly_accessible", + "emr_cluster_master_nodes_no_public_ip", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_public_network_access_disabled", + "postgresql_flexible_server_allow_access_services_disabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_ip", + "cloudstorage_bucket_public_access", + "compute_image_not_publicly_shared", + "compute_instance_public_ip", + "kms_key_not_publicly_accessible", + "secretmanager_secret_not_publicly_accessible" + ], + "alibabacloud": [ + "cs_kubernetes_private_cluster_enabled", + "oss_bucket_not_publicly_accessible", + "rds_instance_no_public_access_whitelist" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted", + "objectstorage_bucket_not_publicly_accessible" + ], + "m365": [ + "admincenter_groups_not_public_visibility", + "sharepoint_external_sharing_restricted", + "teams_meeting_anonymous_user_join_disabled", + "teams_meeting_anonymous_user_start_disabled", + "teams_meeting_chat_anonymous_users_disabled", + "teams_meeting_external_lobby_bypass_disabled" + ] + } + }, + { + "id": "AT.L2-3.2.1", + "description": "Ensure that managers, systems administrators, and users of organizational systems are made aware of the security risks associated with their activities and of the applicable policies, standards, and procedures related to the security of those systems.", + "attributes": { + "Domain": "Awareness and Training", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.2.1" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AT.L2-3.2.2", + "description": "Ensure that personnel are trained to carry out their assigned information security-related duties and responsibilities.", + "attributes": { + "Domain": "Awareness and Training", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.2.2" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AT.L2-3.2.3", + "description": "Provide security awareness training on recognizing and reporting potential indicators of insider threat.", + "attributes": { + "Domain": "Awareness and Training", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.2.3" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AU.L2-3.3.1", + "description": "Create and retain system audit logs and records to the extent needed to enable the monitoring, analysis, investigation, and reporting of unlawful or unauthorized system activity", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.1" + }, + "checks": { + "aws": [ + "apigateway_restapi_logging_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "guardduty_is_enabled", + "rds_instance_integration_cloudwatch_logs", + "s3_bucket_server_access_logging_enabled", + "securityhub_enabled", + "vpc_flow_logs_enabled" + ], + "azure": [ + "app_http_logs_enabled", + "keyvault_logging_enabled", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days" + ], + "gcp": [ + "cloudsql_instance_postgres_enable_pgaudit_flag", + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "oss_bucket_logging_enabled", + "rds_instance_sql_audit_enabled", + "rds_instance_sql_audit_retention", + "sls_logstore_retention_period", + "vpc_flow_logs_enabled" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days", + "network_vcn_subnet_flow_logs_enabled", + "objectstorage_bucket_logging_enabled" + ], + "m365": [ + "exchange_mailbox_audit_bypass_disabled", + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365, + "Provider": "aws" + }, + { + "Check": "rds_instance_sql_audit_retention", + "ConfigKey": "min_rds_audit_retention_days", + "Operator": "gte", + "Value": 180, + "Provider": "alibabacloud" + }, + { + "Check": "sls_logstore_retention_period", + "ConfigKey": "min_log_retention_days", + "Operator": "gte", + "Value": 365, + "Provider": "alibabacloud" + }, + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90, + "Provider": "m365" + } + ] + }, + { + "id": "AU.L2-3.3.2", + "description": "Ensure that the actions of individual system users can be uniquely traced to those users, so they can be held accountable for their actions.", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.2" + }, + "checks": { + "aws": [ + "apigateway_restapi_logging_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "guardduty_is_enabled", + "rds_instance_integration_cloudwatch_logs", + "s3_bucket_server_access_logging_enabled" + ], + "azure": [ + "keyvault_logging_enabled", + "monitor_diagnostic_setting_with_appropriate_categories", + "sqlserver_auditing_enabled" + ], + "gcp": [ + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudstorage_audit_logs_enabled", + "iam_audit_logs_enabled" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "oss_bucket_logging_enabled", + "rds_instance_postgresql_log_connections_enabled", + "rds_instance_postgresql_log_disconnections_enabled" + ], + "oraclecloud": [], + "m365": [ + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90, + "Provider": "m365" + } + ] + }, + { + "id": "AU.L2-3.3.3", + "description": "Review and update logged events.", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.3" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AU.L2-3.3.4", + "description": "Alert in the event of an audit logging process failure.", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AU.L2-3.3.5", + "description": "Correlate audit record review, analysis, and reporting processes for investigation and response to indications of unlawful, unauthorized, suspicious, or unusual activity.", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.5" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AU.L2-3.3.6", + "description": "Provide audit record reduction and report generation to support on-demand analysis and reporting.", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.6" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AU.L2-3.3.7", + "description": "Provide a system capability that compares and synchronizes internal system clocks with an authoritative source to generate time stamps for audit records", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.7" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AU.L2-3.3.8", + "description": "Protect audit information and audit logging tools from unauthorized access, modification, and deletion.", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.8" + }, + "checks": { + "aws": [ + "cloudtrail_kms_encryption_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "s3_account_level_public_access_blocks", + "s3_bucket_default_encryption", + "s3_bucket_object_versioning", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access" + ], + "azure": [ + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_versioning_enabled" + ], + "alibabacloud": [ + "actiontrail_oss_bucket_not_publicly_accessible" + ], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AU.L2-3.3.9", + "description": "Limit management of audit logging functionality to a subset of privileged users.", + "attributes": { + "Domain": "Audit and Accountability", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.3.9" + }, + "checks": { + "aws": [ + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_cloudtrail" + ], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CM.L2-3.4.1", + "description": "Establish and maintain baseline configurations and inventories of organizational systems (including hardware, software, firmware, and documentation) throughout the respective system development life cycles.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.1" + }, + "checks": { + "aws": [ + "ec2_instance_managed_by_ssm", + "ssm_managed_compliant_patching" + ], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CM.L2-3.4.2", + "description": "Establish and enforce security configuration settings for information technology products employed in organizational systems.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.2" + }, + "checks": { + "aws": [ + "ec2_instance_managed_by_ssm", + "ec2_instance_older_than_specific_days", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_ensure_system_updates_are_applied", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [], + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied" + ], + "oraclecloud": [ + "compute_instance_legacy_metadata_endpoint_disabled", + "compute_instance_secure_boot_enabled", + "network_default_security_list_restricts_traffic" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "ec2_instance_older_than_specific_days", + "ConfigKey": "max_ec2_instance_age_in_days", + "Operator": "lte", + "Value": 180, + "Provider": "aws" + } + ] + }, + { + "id": "CM.L2-3.4.3", + "description": "Track, review, approve or disapprove, and log changes to organizational systems.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.3" + }, + "checks": { + "aws": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "config_recorder_all_regions_enabled" + ], + "azure": [ + "monitor_diagnostic_setting_with_appropriate_categories" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "alibabacloud": [ + "sls_cloud_firewall_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled", + "sls_logstore_retention_period", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled" + ], + "oraclecloud": [ + "events_rule_network_gateway_changes", + "events_rule_network_security_group_changes", + "events_rule_route_table_changes", + "events_rule_security_list_changes", + "events_rule_vcn_changes" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "sls_logstore_retention_period", + "ConfigKey": "min_log_retention_days", + "Operator": "gte", + "Value": 365, + "Provider": "alibabacloud" + } + ] + }, + { + "id": "CM.L2-3.4.4", + "description": "Analyze the security impact of changes prior to implementation.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CM.L2-3.4.5", + "description": "Define, document, approve, and enforce physical and logical access restrictions associated with changes to organizational systems.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.5" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CM.L2-3.4.6", + "description": "Employ the principle of least functionality by configuring organizational systems to provide only essential capabilities.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.6" + }, + "checks": { + "aws": [ + "awslambda_function_url_public", + "ec2_confidential_workload_host_vsock_proxy_exposed", + "ec2_ebs_public_snapshot", + "ec2_instance_managed_by_ssm", + "ec2_securitygroup_default_restrict_traffic", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "ssm_managed_compliant_patching" + ], + "azure": [], + "gcp": [ + "compute_instance_serial_ports_in_use" + ], + "alibabacloud": [], + "oraclecloud": [ + "compute_instance_legacy_metadata_endpoint_disabled" + ], + "m365": [ + "admincenter_shared_bookings_disabled", + "exchange_roles_assignment_policy_addins_disabled" + ] + }, + "config_requirements": [ + { + "Check": "ec2_confidential_workload_host_vsock_proxy_exposed", + "ConfigKey": "enclave_vsock_ports", + "Operator": "superset", + "Value": [ + 5000, + 8000, + 8001, + 8002, + 8003, + 8004, + 8005, + 8006, + 8007, + 8008, + 8009, + 8010, + 8011, + 8012, + 8013, + 8014, + 8015, + 8016, + 8017, + 8018, + 8019, + 8020, + 8021, + 8022, + 8023, + 8024, + 8025, + 8026, + 8027, + 8028, + 8029, + 8030, + 8031, + 8032, + 8033, + 8034, + 8035, + 8036, + 8037, + 8038, + 8039, + 8040, + 8041, + 8042, + 8043, + 8044, + 8045, + 8046, + 8047, + 8048, + 8049, + 8050, + 8051, + 8052, + 8053, + 8054, + 8055, + 8056, + 8057, + 8058, + 8059, + 8060, + 8061, + 8062, + 8063, + 8064, + 8065, + 8066, + 8067, + 8068, + 8069, + 8070, + 8071, + 8072, + 8073, + 8074, + 8075, + 8076, + 8077, + 8078, + 8079, + 8080, + 8081, + 8082, + 8083, + 8084, + 8085, + 8086, + 8087, + 8088, + 8089, + 8090, + 9000 + ], + "Provider": "aws" + } + ] + }, + { + "id": "CM.L2-3.4.7", + "description": "Restrict, disable, or prevent the use of nonessential programs, functions, ports, protocols, and services.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.7" + }, + "checks": { + "aws": [ + "ec2_confidential_workload_host_vsock_proxy_exposed", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic" + ], + "azure": [ + "app_ftp_deployment_disabled", + "app_function_ftps_deployment_disabled", + "containerregistry_admin_user_disabled", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "alibabacloud": [ + "cs_kubernetes_dashboard_disabled", + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet" + ], + "oraclecloud": [ + "compute_instance_legacy_metadata_endpoint_disabled", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port" + ], + "m365": [ + "entra_legacy_authentication_blocked", + "exchange_organization_modern_authentication_enabled", + "exchange_transport_config_smtp_auth_disabled", + "sharepoint_modern_authentication_required" + ] + }, + "config_requirements": [ + { + "Check": "ec2_confidential_workload_host_vsock_proxy_exposed", + "ConfigKey": "enclave_vsock_ports", + "Operator": "superset", + "Value": [ + 5000, + 8000, + 8001, + 8002, + 8003, + 8004, + 8005, + 8006, + 8007, + 8008, + 8009, + 8010, + 8011, + 8012, + 8013, + 8014, + 8015, + 8016, + 8017, + 8018, + 8019, + 8020, + 8021, + 8022, + 8023, + 8024, + 8025, + 8026, + 8027, + 8028, + 8029, + 8030, + 8031, + 8032, + 8033, + 8034, + 8035, + 8036, + 8037, + 8038, + 8039, + 8040, + 8041, + 8042, + 8043, + 8044, + 8045, + 8046, + 8047, + 8048, + 8049, + 8050, + 8051, + 8052, + 8053, + 8054, + 8055, + 8056, + 8057, + 8058, + 8059, + 8060, + 8061, + 8062, + 8063, + 8064, + 8065, + 8066, + 8067, + 8068, + 8069, + 8070, + 8071, + 8072, + 8073, + 8074, + 8075, + 8076, + 8077, + 8078, + 8079, + 8080, + 8081, + 8082, + 8083, + 8084, + 8085, + 8086, + 8087, + 8088, + 8089, + 8090, + 9000 + ], + "Provider": "aws" + } + ] + }, + { + "id": "CM.L2-3.4.8", + "description": "Apply deny-by-exception (blacklisting) policy to prevent the use of unauthorized software or deny-all, permit-by-exception (whitelisting) policy to allow the execution of authorized software.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.8" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CM.L2-3.4.9", + "description": "Control and monitor user-installed software.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.4.9" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "entra_admin_consent_workflow_enabled", + "entra_policy_restricts_user_consent_for_apps", + "entra_thirdparty_integrated_apps_not_allowed" + ] + } + }, + { + "id": "IA.L2-3.5.1", + "description": "Identify system users, processes acting on behalf of users, and devices.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.1" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IA.L2-3.5.2", + "description": "Authenticate (or verify) the identities of users, processes, or devices, as a prerequisite to allowing access to organizational systems.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.2" + }, + "checks": { + "aws": [ + "iam_root_mfa_enabled", + "iam_user_mfa_enabled_console_access" + ], + "azure": [ + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled" + ], + "gcp": [ + "compute_project_os_login_enabled" + ], + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "oraclecloud": [ + "identity_instance_principal_used", + "identity_user_mfa_enabled_console_access" + ], + "m365": [ + "entra_admin_users_mfa_enabled", + "entra_users_mfa_enabled" + ] + } + }, + { + "id": "IA.L2-3.5.3", + "description": "Use multifactor authentication for local and network access to privileged accounts and for network access to non-privileged accounts.[24] [25].", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.3" + }, + "checks": { + "aws": [ + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled", + "iam_user_mfa_enabled_console_access" + ], + "azure": [ + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled", + "entra_user_with_vm_access_has_mfa" + ], + "gcp": [ + "compute_project_os_login_2fa_enabled" + ], + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "oraclecloud": [ + "identity_user_mfa_enabled_console_access" + ], + "m365": [ + "entra_admin_users_mfa_enabled", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_users_mfa_enabled" + ] + } + }, + { + "id": "IA.L2-3.5.4", + "description": "Employ replay-resistant authentication mechanisms for network access to privileged and non-privileged accounts.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.4" + }, + "checks": { + "aws": [], + "azure": [ + "entra_authentication_methods_policy_strong_auth_enforced" + ], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_authentication_method_sms_voice_disabled" + ] + } + }, + { + "id": "IA.L2-3.5.5", + "description": "Prevent reuse of identifiers for a defined period.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.5" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IA.L2-3.5.6", + "description": "Disable identifiers after a defined period of inactivity.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.6" + }, + "checks": { + "aws": [ + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_password_policy_reuse_24", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "azure": [ + "entra_user_with_recent_sign_in" + ], + "gcp": [ + "iam_service_account_unused" + ], + "alibabacloud": [ + "ram_user_console_access_unused" + ], + "oraclecloud": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + }, + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_service_account_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 180, + "Provider": "gcp" + }, + { + "Check": "ram_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "alibabacloud" + } + ] + }, + { + "id": "IA.L2-3.5.7", + "description": "Enforce a minimum password complexity and change of characters when new passwords are created.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.7" + }, + "checks": { + "aws": [ + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_password_policy_lowercase", + "iam_password_policy_minimum_length_14", + "iam_password_policy_number", + "iam_password_policy_reuse_24", + "iam_password_policy_symbol", + "iam_password_policy_uppercase" + ], + "azure": [], + "gcp": [], + "alibabacloud": [ + "ram_password_policy_lowercase", + "ram_password_policy_minimum_length", + "ram_password_policy_number", + "ram_password_policy_symbol", + "ram_password_policy_uppercase" + ], + "oraclecloud": [ + "identity_password_policy_minimum_length_14" + ], + "m365": [] + } + }, + { + "id": "IA.L2-3.5.8", + "description": "Prohibit password reuse for a specified number of generations.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.8" + }, + "checks": { + "aws": [ + "iam_password_policy_reuse_24" + ], + "azure": [], + "gcp": [], + "alibabacloud": [ + "ram_password_policy_password_reuse_prevention" + ], + "oraclecloud": [ + "identity_password_policy_prevents_reuse" + ], + "m365": [] + } + }, + { + "id": "IA.L2-3.5.9", + "description": "Allow temporary password use for system logons with an immediate change to a permanent password.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.9" + }, + "checks": { + "aws": [ + "cognito_user_pool_temporary_password_expiration" + ], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IA.L2-3.5.10", + "description": "Store and transmit only cryptographically-protected passwords.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.10" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IA.L2-3.5.11", + "description": "Obscure feedback of authentication information", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.5.11" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IR.L2-3.6.1", + "description": "Establish an operational incident-handling capability for organizational systems that includes preparation, detection, analysis, containment, recovery, and user response activities.", + "attributes": { + "Domain": "Incident Response", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.6.1" + }, + "checks": { + "aws": [ + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "securityhub_enabled" + ], + "azure": [ + "defender_ensure_notify_alerts_severity_is_high", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "sls_cloud_firewall_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days", + "cloudguard_enabled", + "events_rule_cloudguard_problems", + "events_rule_iam_group_changes", + "events_rule_iam_policy_changes", + "events_rule_identity_provider_changes", + "events_rule_idp_group_mapping_changes", + "events_rule_network_gateway_changes", + "events_rule_network_security_group_changes", + "events_rule_route_table_changes", + "events_rule_security_list_changes", + "events_rule_user_changes", + "events_rule_vcn_changes", + "network_vcn_subnet_flow_logs_enabled", + "objectstorage_bucket_logging_enabled" + ], + "m365": [ + "defenderidentity_health_issues_no_open", + "teams_security_reporting_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "IR.L2-3.6.2", + "description": "Track, document, and report incidents to designated officials and/or authorities both internal and external to the organization.", + "attributes": { + "Domain": "Incident Response", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.6.2" + }, + "checks": { + "aws": [], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners" + ], + "gcp": [ + "iam_organization_essential_contacts_configured" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk" + ], + "oraclecloud": [], + "m365": [ + "defender_chat_report_policy_configured" + ] + } + }, + { + "id": "IR.L2-3.6.3", + "description": "Test the organizational incident response capability.", + "attributes": { + "Domain": "Incident Response", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.6.3" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MA.L2-3.7.1", + "description": "Perform maintenance on organizational systems.[26].", + "attributes": { + "Domain": "Maintenance", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.7.1" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MA.L2-3.7.2", + "description": "Provide controls on the tools, techniques, mechanisms, and personnel used to conduct system maintenance.", + "attributes": { + "Domain": "Maintenance", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.7.2" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MA.L2-3.7.3", + "description": "Ensure equipment removed for off-site maintenance is sanitized of any CUI.", + "attributes": { + "Domain": "Maintenance", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.7.3" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MA.L2-3.7.4", + "description": "Check media containing diagnostic and test programs for malicious code before the media are used in organizational systems.", + "attributes": { + "Domain": "Maintenance", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.7.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MA.L2-3.7.5", + "description": "Require multifactor authentication to establish nonlocal maintenance sessions via external network connections and terminate such connections when nonlocal maintenance is complete.", + "attributes": { + "Domain": "Maintenance", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.7.5" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MA.L2-3.7.6", + "description": "Supervise the maintenance activities of maintenance personnel without required access authorization.", + "attributes": { + "Domain": "Maintenance", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.7.6" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.1", + "description": "Protect (i.e., physically control and securely store) system media containing CUI, both paper and digital.", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.1" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.2", + "description": "Limit access to CUI on system media to authorized users", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.2" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.3", + "description": "Sanitize or destroy system media containing CUI before disposal or release for reuse.", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.3" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.4", + "description": "Mark media with necessary CUI markings and distribution limitations.[27]", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.5", + "description": "Control access to media containing CUI and maintain accountability for media during transport outside of controlled areas.", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.5" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.6", + "description": "Implement cryptographic mechanisms to protect the confidentiality of CUI stored on digital media during transport unless otherwise protected by alternative physical safeguards.", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.6" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.7", + "description": "Control the use of removable media on system components.", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.7" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.8", + "description": "Prohibit the use of portable storage devices when such devices have no identifiable owner.", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.8" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "MP.L2-3.8.9", + "description": "Protect the confidentiality of backup CUI at storage locations.", + "attributes": { + "Domain": "Media Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.8.9" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PS.L2-3.9.1", + "description": "Screen individuals prior to authorizing access to organizational systems containing CUI.", + "attributes": { + "Domain": "Personnel Security", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.9.1" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PS.L2-3.9.2", + "description": "Ensure that organizational systems containing CUI are protected during and after personnel actions such as terminations and transfers", + "attributes": { + "Domain": "Personnel Security", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.9.2" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PE.L2-3.10.1", + "description": "Limit physical access to organizational systems, equipment, and the respective operating environments to authorized individuals.", + "attributes": { + "Domain": "Physical Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.10.1" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PE.L2-3.10.2", + "description": "Protect and monitor the physical facility and support infrastructure for organizational systems.", + "attributes": { + "Domain": "Physical Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.10.2" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PE.L2-3.10.3", + "description": "Escort visitors and monitor visitor activity.", + "attributes": { + "Domain": "Physical Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.10.3" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PE.L2-3.10.4", + "description": "Maintain audit logs of physical access.", + "attributes": { + "Domain": "Physical Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.10.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PE.L2-3.10.5", + "description": "Control and manage physical access devices.", + "attributes": { + "Domain": "Physical Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.10.5" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PE.L2-3.10.6", + "description": "Enforce safeguarding measures for CUI at alternate work sites.", + "attributes": { + "Domain": "Physical Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.10.6" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L2-3.11.1", + "description": "Periodically assess the risk to organizational operations (including mission, functions, image, or reputation), organizational assets, and individuals, resulting from the operation of organizational systems and the associated processing, storage, or transmission of CUI", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.11.1" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L2-3.11.2", + "description": "Scan for vulnerabilities in organizational systems and applications periodically and when new vulnerabilities affecting those systems and applications are identified.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.11.2" + }, + "checks": { + "aws": [ + "inspector2_is_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_scan_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled" + ], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L2-3.11.3", + "description": "Remediate vulnerabilities in accordance with risk assessments.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.11.3" + }, + "checks": { + "aws": [ + "inspector2_active_findings_exist" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities", + "defender_ensure_system_updates_are_applied" + ], + "gcp": [], + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied" + ], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CA.L2-3.12.1", + "description": "Periodically assess the security controls in organizational systems to determine if the controls are effective in their application.", + "attributes": { + "Domain": "Security Assessment", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.12.1" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CA.L2-3.12.2", + "description": "Develop and implement plans of action designed to correct deficiencies and reduce or eliminate vulnerabilities in organizational systems.", + "attributes": { + "Domain": "Security Assessment", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.12.2" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CA.L2-3.12.3", + "description": "Monitor security controls on an ongoing basis to ensure the continued effectiveness of the controls.", + "attributes": { + "Domain": "Security Assessment", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.12.3" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CA.L2-3.12.4", + "description": "Develop, document, and periodically update system security plans that describe system boundaries, system environments of operation, how security requirements are implemented, and the relationships with or connections to other systems.[28]", + "attributes": { + "Domain": "Security Assessment", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.12.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.1", + "description": "Monitor, control, and protect communications (i.e., information transmitted or received by organizational systems) at the external boundaries and key internal boundaries of organizational systems.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.1" + }, + "checks": { + "aws": [ + "acm_certificates_expiration_check", + "apigateway_restapi_logging_enabled", + "awslambda_function_not_publicly_accessible", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "elb_logging_enabled", + "elb_ssl_listeners", + "elbv2_logging_enabled", + "elbv2_waf_acl_attached", + "guardduty_is_enabled", + "rds_instance_integration_cloudwatch_logs", + "rds_instance_no_public_access", + "redshift_cluster_public_access", + "s3_bucket_secure_transport_policy", + "s3_bucket_server_access_logging_enabled", + "securityhub_enabled", + "vpc_flow_logs_enabled" + ], + "azure": [ + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", + "network_udp_internet_access_restricted" + ], + "gcp": [ + "cloudstorage_uses_vpc_service_controls", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_subnet_flow_logs_enabled" + ], + "alibabacloud": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet", + "oss_bucket_secure_transport_enabled", + "rds_instance_no_public_access_whitelist", + "rds_instance_ssl_enabled", + "sls_security_group_changes_alert_enabled", + "vpc_flow_logs_enabled" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "network_vcn_subnet_flow_logs_enabled" + ], + "m365": [ + "defender_antispam_connection_filter_policy_empty_ip_allowlist", + "defender_antispam_connection_filter_policy_safe_list_off", + "exchange_application_access_policy_restricts_mailbox_apps", + "sharepoint_external_sharing_managed", + "sharepoint_external_sharing_restricted", + "teams_external_domains_restricted", + "teams_external_users_cannot_start_conversations", + "teams_unmanaged_communication_disabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + } + ] + }, + { + "id": "SC.L2-3.13.2", + "description": "Employ architectural designs, software development techniques, and systems engineering principles that promote effective information security within organizational systems.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.2" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.3", + "description": "Separate user functionality from system management functionality.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.3" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.4", + "description": "Prevent unauthorized and unintended information transfer via shared system resources.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.5", + "description": "Implement subnetworks for publicly accessible system components that are physically or logically separated from internal networks.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.5" + }, + "checks": { + "aws": [ + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "ec2_confidential_workload_host_public_ip", + "ec2_ebs_public_snapshot", + "ec2_instance_public_ip", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic", + "elb_ssl_listeners", + "elbv2_waf_acl_attached", + "emr_cluster_master_nodes_no_public_ip", + "opensearch_service_domains_node_to_node_encryption_enabled", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "redshift_cluster_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "s3_bucket_secure_transport_policy", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_created_with_private_nodes", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_public_network_access_disabled", + "cosmosdb_account_use_private_endpoints", + "databricks_workspace_no_public_ip_enabled", + "databricks_workspace_public_network_access_disabled", + "databricks_workspace_vnet_injection_enabled", + "keyvault_access_only_through_private_endpoints", + "keyvault_private_endpoints", + "network_subnet_nsg_associated", + "postgresql_flexible_server_allow_access_services_disabled", + "storage_account_public_network_access_disabled", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "gcp": [ + "cloudfunction_function_inside_vpc", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_instance_public_ip", + "compute_network_default_in_use", + "compute_network_not_legacy" + ], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.6", + "description": "Deny network communications traffic by default and allow network communications traffic by exception (i.e., deny all, permit by exception).", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.6" + }, + "checks": { + "aws": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_default_restrict_traffic" + ], + "azure": [ + "cosmosdb_account_firewall_use_selected_networks", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "sqlserver_unrestricted_inbound_access", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "cloudsql_instance_public_access", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_default_in_use" + ], + "alibabacloud": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port" + ], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.7", + "description": "Prevent remote devices from simultaneously establishing non-remote connections with organizational systems and communicating via some other connection to resources in external networks (i.e., split tunneling).", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.7" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.8", + "description": "Implement cryptographic mechanisms to prevent unauthorized disclosure of CUI during transmission unless otherwise protected by alternative physical safeguards.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.8" + }, + "checks": { + "aws": [ + "acm_certificates_expiration_check", + "elb_ssl_listeners", + "opensearch_service_domains_node_to_node_encryption_enabled", + "s3_bucket_secure_transport_policy" + ], + "azure": [ + "app_ensure_http_is_redirected_to_https", + "app_function_ensure_http_is_redirected_to_https", + "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "storage_smb_protocol_version_is_latest" + ], + "gcp": [ + "cloudsql_instance_ssl_connections" + ], + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ], + "oraclecloud": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ], + "Provider": "azure" + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ], + "Provider": "azure" + }, + { + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + } + ] + }, + { + "id": "SC.L2-3.13.9", + "description": "Terminate network connections associated with communications sessions at the end of the sessions or after a defined period of inactivity.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.9" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "entra_admin_users_sign_in_frequency_enabled", + "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced" + ] + }, + "config_requirements": [ + { + "Check": "entra_admin_users_sign_in_frequency_enabled", + "ConfigKey": "sign_in_frequency", + "Operator": "lte", + "Value": 4, + "Provider": "m365" + } + ] + }, + { + "id": "SC.L2-3.13.10", + "description": "Establish and manage cryptographic keys for cryptography employed in organizational systems.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.10" + }, + "checks": { + "aws": [ + "kms_cmk_not_deleted_unintentionally", + "kms_cmk_rotation_enabled", + "kms_key_not_publicly_accessible" + ], + "azure": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_rbac_enabled", + "keyvault_rbac_key_expiration_set", + "keyvault_recoverable" + ], + "gcp": [ + "iam_role_kms_enforce_separation_of_duties", + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled", + "kms_key_rotation_max_90_days" + ], + "alibabacloud": [ + "rds_instance_tde_key_custom" + ], + "oraclecloud": [ + "kms_key_rotation_enabled" + ], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.11", + "description": "Employ FIPS-validated cryptography when used to protect the confidentiality of CUI.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.11" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.12", + "description": "Prohibit remote activation of collaborative computing devices and provide indication of devices in use to users present at the device.[29].", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.12" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "teams_meeting_external_control_disabled" + ] + } + }, + { + "id": "SC.L2-3.13.13", + "description": "Control and monitor the use of mobile code.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.13" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.14", + "description": "Control and monitor the use of Voice over Internet Protocol (VoIP) technologies.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.14" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L2-3.13.15", + "description": "Protect the authenticity of communications sessions.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.15" + }, + "checks": { + "aws": [ + "elb_ssl_listeners" + ], + "azure": [ + "app_client_certificates_on" + ], + "gcp": [ + "cloudsql_instance_ssl_connections" + ], + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ], + "oraclecloud": [], + "m365": [ + "defender_antiphishing_policy_configured", + "defender_domain_dkim_enabled", + "exchange_organization_reject_direct_send_enabled" + ] + } + }, + { + "id": "SC.L2-3.13.16", + "description": "Protect the confidentiality of CUI at rest.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.13.16" + }, + "checks": { + "aws": [ + "cloudtrail_kms_encryption_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "ec2_ebs_volume_encryption", + "efs_encryption_at_rest_enabled", + "opensearch_service_domains_encryption_at_rest_enabled", + "rds_instance_storage_encrypted", + "s3_bucket_default_encryption", + "sagemaker_notebook_instance_encryption_enabled", + "sns_topics_kms_encryption_at_rest_enabled" + ], + "azure": [ + "databricks_workspace_cmk_encryption_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "gcp": [ + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "cloudsql_instance_cmek_encryption_enabled", + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled" + ], + "alibabacloud": [ + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom" + ], + "oraclecloud": [ + "blockstorage_block_volume_encrypted_with_cmk", + "blockstorage_boot_volume_encrypted_with_cmk", + "filestorage_file_system_encrypted_with_cmk", + "objectstorage_bucket_encrypted_with_cmk" + ], + "m365": [] + } + }, + { + "id": "SI.L2-3.14.1", + "description": "Identify, report, and correct system flaws in a timely manner.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.14.1" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "securityhub_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_ensure_system_updates_are_applied" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied", + "securitycenter_vulnerability_scan_enabled" + ], + "oraclecloud": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "SI.L2-3.14.2", + "description": "Provide protection from malicious code at designated locations within organizational systems.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.14.2" + }, + "checks": { + "aws": [ + "guardduty_ec2_malware_protection_enabled", + "guardduty_s3_protection_enabled" + ], + "azure": [ + "defender_assessments_vm_endpoint_protection_installed" + ], + "gcp": [], + "alibabacloud": [ + "ecs_instance_endpoint_protection_installed" + ], + "oraclecloud": [], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "defender_safe_attachments_policy_enabled", + "defender_strict_preset_security_policy_enabled", + "defender_zap_for_teams_enabled" + ] + }, + "config_requirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ], + "Provider": "m365" + } + ] + }, + { + "id": "SI.L2-3.14.3", + "description": "Monitor system security alerts and advisories and take action in response.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.14.3" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "securityhub_enabled", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners" + ], + "gcp": [ + "iam_organization_essential_contacts_configured" + ], + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied", + "securitycenter_notification_enabled_high_risk" + ], + "oraclecloud": [ + "events_rule_cloudguard_problems" + ], + "m365": [ + "defender_malware_policy_notifications_internal_users_malware_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "SI.L2-3.14.4", + "description": "Update malicious code protection mechanisms when new releases are available.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.14.4" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SI.L2-3.14.5", + "description": "Perform periodic scans of organizational systems and real-time scans of files from external sources as files are downloaded, opened, or executed.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.14.5" + }, + "checks": { + "aws": [ + "guardduty_ec2_malware_protection_enabled" + ], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_safe_attachments_policy_enabled", + "defender_zap_for_teams_enabled" + ] + } + }, + { + "id": "SI.L2-3.14.6", + "description": "Monitor organizational systems, including inbound and outbound communications traffic, to detect attacks and indicators of potential attacks.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.14.6" + }, + "checks": { + "aws": [ + "apigateway_restapi_logging_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "guardduty_is_enabled", + "rds_instance_integration_cloudwatch_logs", + "s3_bucket_server_access_logging_enabled", + "securityhub_enabled", + "vpc_flow_logs_enabled" + ], + "azure": [ + "network_flow_log_captured_sent" + ], + "gcp": [ + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_subnet_flow_logs_enabled" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "vpc_flow_logs_enabled" + ], + "oraclecloud": [ + "network_vcn_subnet_flow_logs_enabled" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "SI.L2-3.14.7", + "description": "Identify unauthorized use of organizational systems.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 2", + "SourceRequirement": "NIST SP 800-171 Rev 2 3.14.7" + }, + "checks": { + "aws": [ + "apigateway_restapi_logging_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "guardduty_is_enabled", + "rds_instance_integration_cloudwatch_logs", + "s3_bucket_server_access_logging_enabled", + "securityhub_enabled", + "vpc_flow_logs_enabled" + ], + "azure": [ + "defender_ensure_wdatp_is_enabled", + "keyvault_logging_enabled", + "monitor_diagnostic_setting_with_appropriate_categories", + "sqlserver_auditing_enabled" + ], + "gcp": [ + "cloudstorage_audit_logs_enabled", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled" + ], + "oraclecloud": [ + "events_rule_local_user_authentication", + "events_rule_user_changes" + ], + "m365": [ + "defender_antispam_outbound_policy_configured", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled", + "purview_audit_log_search_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "AC.L3-3.1.2e", + "description": "Restrict access to systems and system components to only those information resources that are owned, provisioned, or issued by the organization.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.1.2e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AC.L3-3.1.3e", + "description": "Employ secure information transfer solutions to control information flows between security domains on connected systems.", + "attributes": { + "Domain": "Access Control", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.1.3e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AT.L3-3.2.1e", + "description": "Provide awareness training upon initial hire, following a significant cyber event, and at least annually, focused on recognizing and responding to threats from social engineering, advanced persistent threat actors, breaches, and suspicious behaviors; update the training at least annually or when there are significant changes to the threat.", + "attributes": { + "Domain": "Awareness and Training", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.2.1e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "AT.L3-3.2.2e", + "description": "Include practical exercises in awareness training for all users, tailored by roles, to include general users, users with specialized roles, and privileged users, that are aligned with current threat scenarios and provide feedback to individuals involved in the training and their supervisors.", + "attributes": { + "Domain": "Awareness and Training", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.2.2e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CM.L3-3.4.1e", + "description": "Establish and maintain an authoritative source and repository to provide a trusted source and accountability for approved and implemented system components.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.4.1e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CM.L3-3.4.2e", + "description": "Employ automated mechanisms to detect misconfigured or unauthorized system components; after detection, remove the components or place the components in a quarantine or remediation network to facilitate patching, re-configuration, or other mitigations.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.4.2e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CM.L3-3.4.3e", + "description": "Employ automated discovery and management tools to maintain an up-to-date, complete, accurate, and readily available inventory of system components.", + "attributes": { + "Domain": "Configuration Management", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.4.3e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IA.L3-3.5.1e", + "description": "Identify and authenticate systems and system components, where possible, before establishing a network connection using bidirectional authentication that is cryptographically based and replay resistant.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.5.1e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IA.L3-3.5.3e", + "description": "Employ automated or manual/procedural mechanisms to prohibit system components from connecting to organizational systems unless the components are known, authenticated, in a properly configured state, or in a trust profile.", + "attributes": { + "Domain": "Identification and Authentication", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.5.3e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IR.L3-3.6.1e", + "description": "Establish and maintain a security operations center capability that operates 24/7, with allowance for remote/on-call staff.", + "attributes": { + "Domain": "Incident Response", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.6.1e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "IR.L3-3.6.2e", + "description": "Establish and maintain a cyber-incident response team that can be deployed by the organization within 24 hours.", + "attributes": { + "Domain": "Incident Response", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.6.2e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "PS.L3-3.9.2e", + "description": "Ensure that organizational systems are protected if adverse information develops or is obtained about individuals with access to CUI.", + "attributes": { + "Domain": "Personnel Security", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.9.2e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L3-3.11.1e", + "description": "Employ threat intelligence, at a minimum from open or commercial sources, and any DoD-provided sources, as part of a risk assessment to guide and inform the development of organizational systems, security architectures, selection of security solutions, monitoring, threat hunting, and response and recovery activities.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.11.1e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L3-3.11.2e", + "description": "Conduct cyber threat hunting activities on an on-going aperiodic basis or when indications warrant, to search for indicators of compromise in organizational systems and detect, track, and disrupt threats that evade existing controls.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.11.2e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L3-3.11.3e", + "description": "Employ advanced automation and analytics capabilities in support of analysts to predict and identify risks to organizations, systems, and system components.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.11.3e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L3-3.11.4e", + "description": "Document or reference in the system security plan the security solution selected, the rationale for the security solution, and the risk determination.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.11.4e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L3-3.11.5e", + "description": "Assess the effectiveness of security solutions at least annually or upon receipt of relevant cyber threat information, or in response to a relevant cyber incident, to address anticipated risk to organizational systems and the organization based on current and accumulated threat intelligence.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.11.5e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L3-3.11.6e", + "description": "Assess, respond to, and monitor supply chain risks associated with organizational systems and system components.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.11.6e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "RA.L3-3.11.7e", + "description": "Develop a plan for managing supply chain risks associated with organizational systems and system components; update the plan at least annually, and upon receipt of relevant cyber threat information, or in response to a relevant cyber incident.", + "attributes": { + "Domain": "Risk Assessment", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.11.7e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "CA.L3-3.12.1e", + "description": "Conduct penetration testing at least annually or when significant security changes are made to the system, leveraging automated scanning tools and ad hoc tests using subject matter experts.", + "attributes": { + "Domain": "Security Assessment", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.12.1e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SC.L3-3.13.4e", + "description": "Employ physical isolation techniques or logical isolation techniques or both in organizational systems and system components.", + "attributes": { + "Domain": "System and Communications Protection", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.13.4e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SI.L3-3.14.1e", + "description": "Verify the integrity of security critical and essential software using root of trust mechanisms or cryptographic signatures.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.14.1e" + }, + "checks": { + "aws": [], + "azure": [ + "vm_trusted_launch_enabled" + ], + "gcp": [ + "compute_instance_shielded_vm_enabled" + ], + "alibabacloud": [], + "oraclecloud": [ + "compute_instance_secure_boot_enabled" + ], + "m365": [] + } + }, + { + "id": "SI.L3-3.14.3e", + "description": "Ensure that specialized assets including IoT, IIoT, OT, GFE, Restricted Information Systems, and test equipment are included in the scope of the specified enhanced security requirements or are segregated in purpose-specific networks.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.14.3e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + }, + { + "id": "SI.L3-3.14.6e", + "description": "Use threat indicator information and effective mitigations obtained from, at a minimum, open or commercial sources, and any DoD-provided sources, to guide and inform intrusion detection and threat hunting.", + "attributes": { + "Domain": "System and Information Integrity", + "Level": "Level 3", + "SourceRequirement": "NIST SP 800-172 Feb2021 3.14.6e" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "alibabacloud": [], + "oraclecloud": [], + "m365": [] + } + } + ] +} diff --git a/prowler/compliance/m365/cis_7.0_m365.json b/prowler/compliance/m365/cis_7.0_m365.json index 63056dcc22..7cc1315275 100644 --- a/prowler/compliance/m365/cis_7.0_m365.json +++ b/prowler/compliance/m365/cis_7.0_m365.json @@ -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", @@ -1783,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", @@ -1896,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", @@ -1917,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", @@ -1938,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", @@ -1959,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", @@ -1980,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", @@ -2001,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", @@ -2135,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", @@ -2349,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", @@ -2370,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", @@ -2391,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", @@ -2412,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", diff --git a/prowler/config/config.py b/prowler/config/config.py index e2732a90f1..6d2e71132f 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -49,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.39.0" +prowler_version = "5.40.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" diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 1dcbfb5416..60428bede7 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -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"""
@@ -471,7 +476,7 @@ class HTML(Output):
  • - AWS Account: {provider.identity.account} + AWS Account: {account}
  • AWS-CLI Profile: {profile} @@ -489,10 +494,10 @@ class HTML(Output):
  • - User Id: {provider.identity.user_id} + User Id: {user_id}
  • - Caller Identity ARN: {provider.identity.identity_arn} + Caller Identity ARN: {identity_arn}
@@ -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"""
@@ -538,13 +548,13 @@ class HTML(Output):
  • - Azure Tenant IDs: {" ".join(provider.identity.tenant_ids)} + Azure Tenant IDs: {tenant_ids}
  • - Azure Tenant Domain: {provider.identity.tenant_domain} + Azure Tenant Domain: {tenant_domain}
  • - Azure Subscriptions: {" ".join(printed_subscriptions)} + Azure Subscriptions: {subscriptions}
@@ -556,7 +566,7 @@ class HTML(Output):
  • - Azure Identity Type: {provider.identity.identity_type} + Azure Identity Type: {identity_type}
  • Azure Identity ID: {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"""
    @@ -599,7 +611,7 @@ class HTML(Output):
    • - GCP Project IDs: {", ".join(provider.project_ids)} + GCP Project IDs: {project_ids}
    @@ -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"""
    @@ -643,7 +657,7 @@ class HTML(Output):
    • - Kubernetes Cluster: {provider.identity.cluster} + Kubernetes Cluster: {cluster}
    @@ -656,7 +670,7 @@ class HTML(Output):
    • - Kubernetes Context: {provider.identity.context} + Kubernetes Context: {context}
    @@ -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"""
  • - GitHub account: {provider.identity.account_name} + GitHub account: {account_name}
  • """ # 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"""
  • - GitHub account email: {provider.identity.account_email} + GitHub account email: {account_email}
  • """ 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"""
  • - GitHub App Name: {provider.identity.app_name} + GitHub App Name: {app_name}
  • """ # 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"""
  • Installations: {installations_display} @@ -719,26 +739,27 @@ class HTML(Output):
  • """ # Credentials items: Authentication method and App ID + app_id = escape(str(provider.identity.app_id)) credentials_items = f"""
  • - GitHub authentication method: {provider.auth_method} + GitHub authentication method: {auth_method}
  • - GitHub App ID: {provider.identity.app_id} + GitHub App ID: {app_id}
  • """ else: # Fallback for other identity types account_info_items = "" credentials_items = f"""
  • - GitHub authentication method: {provider.auth_method} + GitHub authentication method: {auth_method}
  • """ # For PAT/OAuth, use default credentials structure if hasattr(provider.identity, "account_name"): credentials_items = f"""
  • - GitHub authentication method: {provider.auth_method} + GitHub authentication method: {auth_method}
  • """ 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"""
  • + M365 User: {user} +
  • """ return f"""
    @@ -787,9 +820,7 @@ class HTML(Output):
    • - M365 Tenant Domain: { - provider.identity.tenant_domain - } + M365 Tenant Domain: {tenant_domain}
    @@ -801,19 +832,12 @@ class HTML(Output):
    • - M365 Identity Type: {provider.identity.identity_type} + M365 Identity Type: {identity_type}
    • - M365 Identity ID: {provider.identity.identity_id} + M365 Identity ID: {identity_id}
    • - { - f'''
    • - M365 User: {provider.identity.user} -
    • ''' - if hasattr(provider.identity, "user") - and provider.identity.user is not None - else "" - } + {user_item}
    """ @@ -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"""
    @@ -842,7 +869,7 @@ class HTML(Output):
    • - NHN Tenant Domain: {provider.identity.tenant_domain} + NHN Tenant Domain: {tenant_domain}
    @@ -854,10 +881,10 @@ class HTML(Output):
    • - NHN Identity Type: {provider.identity.identity_type} + NHN Identity Type: {identity_type}
    • - NHN Identity ID: {provider.identity.identity_id} + NHN Identity ID: {identity_id}
    @@ -880,6 +907,7 @@ class HTML(Output): str: the HTML assessment summary """ try: + organization_name = escape(str(provider.identity.organization_name)) return f"""
    @@ -889,7 +917,7 @@ class HTML(Output):
    • - MongoDB Atlas organization: {provider.identity.organization_name} + MongoDB Atlas organization: {organization_name}
    @@ -925,6 +953,13 @@ class HTML(Output): str: the HTML assessment summary """ try: + if provider.scan_repository_url: + target_info = "IAC repository URL: " + str( + escape(str(provider.scan_repository_url)) + ) + else: + target_info = "IAC path: " + str(escape(str(provider.scan_path))) + auth_method = escape(str(provider.auth_method)) return f"""
    @@ -934,7 +969,7 @@ class HTML(Output):
    • - {"IAC repository URL: " + provider.scan_repository_url if provider.scan_repository_url else "IAC path: " + provider.scan_path} + {target_info}
    @@ -947,7 +982,7 @@ class HTML(Output):
    • - IAC authentication method: {provider.auth_method} + IAC authentication method: {auth_method}
    @@ -971,10 +1006,13 @@ class HTML(Output): """ try: if provider.registry: - target_info = f"Registry URL: {provider.registry}" + registry = escape(str(provider.registry)) + target_info = f"Registry URL: {registry}" else: - target_info = f'Images: {", ".join(provider.images)}' + images = escape(", ".join(provider.images)) + target_info = f"Images: {images}" + auth_method = escape(str(provider.auth_method)) return f"""
    @@ -997,7 +1035,7 @@ class HTML(Output):
    • - Image authentication method: {provider.auth_method} + Image authentication method: {auth_method}
    @@ -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"""
    @@ -1031,16 +1077,16 @@ class HTML(Output):
    • - Target LLM: {provider.model} + Target LLM: {model}
    • - Plugins: {", ".join(provider.plugins)} + Plugins: {plugins}
    • - Max concurrency: {provider.max_concurrency} + Max concurrency: {max_concurrency}
    • - Config file: {provider.config_path if provider.config_path else "Using promptfoo defaults"} + Config file: {config_file}
    @@ -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"""
    @@ -1077,7 +1127,7 @@ class HTML(Output):
    • - OracleCloud Tenancy: {tenancy_name if tenancy_name != "unknown" else tenancy_id} + OracleCloud Tenancy: {tenancy}
    @@ -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"""
  • - Project Name: {project_name} + Project Name: {escape(str(project_name))}
  • """ if project_name else "" @@ -1128,7 +1179,7 @@ class HTML(Output): regions_item = ( f"""
  • - Regions: {", ".join(sorted(audited_regions))} + Regions: {escape(", ".join(sorted(audited_regions)))}
  • """ 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"""
  • Accounts: {accounts} @@ -1208,6 +1259,7 @@ class HTML(Output): provider.session, "api_email", None ) if email: + email = escape(str(email)) credentials_items += f"""
  • Email: {email} @@ -1261,11 +1313,15 @@ class HTML(Output): account_name_item = ( f"""
  • - Account Name: {account_name} + Account Name: {escape(str(account_name))}
  • """ 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"""
    @@ -1326,7 +1382,7 @@ class HTML(Output): project_name_item = ( f"""
  • - Project Name: {project_name} + Project Name: {escape(str(project_name))}
  • """ if project_name else "" @@ -1335,11 +1391,14 @@ class HTML(Output): user_id_item = ( f"""
  • - User ID: {user_id} + User ID: {escape(str(user_id))}
  • """ if user_id else "" ) + project_id = escape(str(project_id)) + region_name = escape(str(region_name)) + username = escape(str(username)) return f"""
    @@ -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"""
    @@ -1397,10 +1459,10 @@ class HTML(Output):
    • - Domain: {provider.identity.domain} + Domain: {domain}
    • - Customer ID: {provider.identity.customer_id} + Customer ID: {customer_id}
    @@ -1412,7 +1474,7 @@ class HTML(Output):
    • - Delegated User: {provider.identity.delegated_user} + Delegated User: {delegated_user}
    • Authentication Method: 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"""
    • - Team: {team.name} ({team.id}) + Team: {team_name} ({team_id})
    • """ credentials_items = """ @@ -1494,6 +1558,7 @@ class HTML(Output): email = getattr(provider.identity, "email", None) if email: + email = escape(str(email)) credentials_items += f"""
    • Email: {email} @@ -1501,6 +1566,7 @@ class HTML(Output): username = getattr(provider.identity, "username", None) if username: + username = escape(str(username)) credentials_items += f"""
    • Username: {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"""
    • - Okta Domain: {provider.identity.org_domain} + Okta Domain: {org_domain}
    • """ credentials_items = f"""
    • - Authentication: {provider.auth_method} + Authentication: {auth_method}
    • - Client ID: {provider.identity.client_id} + Client ID: {client_id}
    • """ 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"""
    • - Organization ID: {provider.identity.organization_id} + Organization ID: {organization_id}
    • """ 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"""
    • Access Key: {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"""
    • Bearer: {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"""
    • Default Region: {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"""
    • Account ID: {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"""
      @@ -1740,10 +1825,10 @@ class HTML(Output):
      • - Account ID: {provider.identity.account_id} + Account ID: {account_id}
      • - Account Name: {provider.identity.account_name} + Account Name: {account_name}
      • Profile: {profile} @@ -1761,16 +1846,16 @@ class HTML(Output):
      • - Domain ID: {provider.identity.domain_id} + Domain ID: {domain_id}
      • - User ID: {provider.identity.user_id} + User ID: {user_id}
      • - User Name: {provider.identity.user_name} + User Name: {user_name}
      • - Identity Type: {provider.identity.identity_type} + Identity Type: {identity_type}
    diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/__init__.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.metadata.json b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.metadata.json new file mode 100644 index 0000000000..0dfa072d65 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "rolesanywhere_profile_restricts_session_permissions", + "CheckTitle": "IAM Roles Anywhere profiles scope down the vended session permissions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "rolesanywhere", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsRolesAnywhereProfile", + "ResourceGroup": "security", + "Description": "**IAM Roles Anywhere profiles** that reference an administrative role are assessed for **session scoping**. A profile defining neither an inline `sessionPolicy` nor `managedPolicyArns` vends credentials with the full permissions of its roles. It is flagged only when a referenced role is administrative, since an unscoped session on a least-privilege role is already constrained.", + "Risk": "Roles Anywhere profiles bind X.509 certificates to IAM roles. When a profile references an administrative role and does not scope the session, vended credentials carry full administrative permissions. An attacker presenting a valid certificate - or planting a rogue trust anchor and profile - gains durable privileged access that rotating IAM keys does not revoke.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html", + "https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html", + "https://docs.aws.amazon.com/rolesanywhere/latest/userguide/security-best-practices.html" + ], + "Remediation": { + "Code": { + "CLI": "aws rolesanywhere update-profile --profile-id --session-policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"\"],\"Resource\":[\"\"]}]}'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::RolesAnywhere::Profile\n Properties:\n Name: scoped-profile\n Enabled: true\n RoleArns:\n - \n SessionPolicy: '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"\"],\"Resource\":[\"\"]}]}' # FIX: scope down the vended session\n```", + "Other": "1. Identify the least-privilege actions the workload actually needs\n2. Attach a sessionPolicy or managedPolicyArns to the Roles Anywhere profile that grants only those actions\n3. Prefer purpose-built roles per workload over broad roles referenced by many profiles\n4. Review trust anchors and profiles regularly for entries you did not create", + "Terraform": "```hcl\nresource \"aws_rolesanywhere_profile\" \"\" {\n name = \"scoped-profile\"\n enabled = true\n role_arns = []\n session_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = []\n Resource = []\n }]\n }) # FIX: scope down the vended session\n}\n```" + }, + "Recommendation": { + "Text": "Attach a session policy or managed policies to every enabled IAM Roles Anywhere profile so the vended credentials are scoped below the referenced role's permissions. Pair each profile with a purpose-built least-privilege role and audit trust anchors and profiles regularly for unexpected entries.", + "Url": "https://hub.prowler.com/check/rolesanywhere_profile_restricts_session_permissions" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "A profile is failed only when it is enabled, its session is unscoped, and a referenced role effectively grants administrative (*:*) access. The session-policy set (inline sessionPolicy plus every managedPolicyArns entry, resolved to its policy document) is evaluated as a union: any member granting *:* leaves the session unrestricted. Role classification merges all attached and inline identity-policy documents so explicit denies negate allows across policies, excludes condition-guarded statements (not statically provable), and intersects the result with the role's permissions boundary: a role whose boundary does not grant *:* - or whose boundary document cannot be resolved - is not classified as administrative. Disabled profiles, scoped profiles, and profiles with no role identified as administrative are reported as PASS. Referenced roles absent from the IAM inventory (for example cross-account roles or denied ListRoles) and policy documents that could not be collected are treated as non-administrative to avoid false positives." +} diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.py new file mode 100644 index 0000000000..5138b74ff3 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.py @@ -0,0 +1,272 @@ +import json + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.logger import logger +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import check_full_service_access +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import ( + rolesanywhere_client, +) + +# AWS-managed AdministratorAccess ARN suffix, partition-agnostic +# (arn:aws:..., arn:aws-cn:..., arn:aws-us-gov:...). +ADMIN_POLICY_ARN_SUFFIX = ":iam::aws:policy/AdministratorAccess" + +# Synthetic statement equivalent to the AWS-managed AdministratorAccess +# document, used when a policy is identified by that ARN but its document was +# not collected by the IAM service. +_ADMIN_STATEMENT = {"Effect": "Allow", "Action": "*", "Resource": "*"} + + +def _grants_full_access(document) -> bool: + """Return True when a policy document grants administrative (``*:*``) access. + + Args: + document: Decoded IAM policy document, or None when unavailable. + """ + if not document: + return False + try: + return check_full_service_access("*", document) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return False + + +def _full_access_status(documents) -> bool | None: + """Return whether a policy-document set grants ``*:*``, or None when unknown. + + Merges every condition-free statement across the given documents into a + single evaluation so an explicit deny in one document negates an allow in + another (Deny > Allow via the shared policy-evaluation helpers). + + Unresolved or malformed documents and context-dependent semantics cannot + produce a definitive classification and propagate as None: + + - a document that is missing or not a well-formed statement container; + - a ``Condition``-guarded Deny that could negate an otherwise proven + full-access grant; + - a ``Condition``-guarded Allow that could grant full access not proven + by the unconditional statements. + + Args: + documents: Iterable of decoded IAM policy documents (None members mark + documents that could not be resolved). + """ + statements = [] + conditional_effects = set() + + for document in documents: + if not isinstance(document, dict) or "Statement" not in document: + return None + + document_statements = document.get("Statement", []) + if not isinstance(document_statements, list): + document_statements = [document_statements] + + for statement in document_statements: + if not isinstance(statement, dict): + return None + + effect = str(statement.get("Effect", "")).lower() + if ( + effect not in {"allow", "deny"} + or not ("Action" in statement or "NotAction" in statement) + or not ("Resource" in statement or "NotResource" in statement) + ): + return None + + if statement.get("Condition"): + conditional_effects.add(effect) + else: + statements.append(statement) + + grants_full_access = _grants_full_access({"Statement": statements}) + if (grants_full_access and "deny" in conditional_effects) or ( + not grants_full_access and "allow" in conditional_effects + ): + return None + + return grants_full_access + + +def _role_is_privileged(role, policies) -> bool | None: + """Return whether an IAM role is administrative, or None when unknown. + + Effective permissions are the intersection of the role's identity policies + (attached and inline, evaluated together) and its permissions boundary. + Unresolved policy documents, malformed policies, and condition-guarded + statements that could change the outcome propagate as None instead of + being collapsed into a definitive classification. + + Args: + role: An ``iam_service.Role`` referenced by a Roles Anywhere profile. + policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client. + """ + documents = [] + for attached in role.attached_policies: + policy_arn = attached.get("PolicyArn", "") + document = getattr(policies.get(policy_arn), "document", None) + if policy_arn.endswith(ADMIN_POLICY_ARN_SUFFIX) and not document: + documents.append({"Statement": [_ADMIN_STATEMENT]}) + else: + documents.append(document) + for inline_name in role.inline_policies: + policy = policies.get(f"{role.arn}:policy/{inline_name}") + documents.append(getattr(policy, "document", None)) + + identity_status = _full_access_status(documents) + if identity_status is False: + # Identity policies provably do not grant *:*; no boundary can widen them. + return False + + boundary = getattr(role, "permissions_boundary", None) + if not boundary: + return identity_status + boundary_arn = ( + boundary.get("PermissionsBoundaryArn", "") if isinstance(boundary, dict) else "" + ) + if boundary_arn.endswith(ADMIN_POLICY_ARN_SUFFIX): + # An AdministratorAccess boundary restricts nothing. + return identity_status + + boundary_status = _full_access_status( + [getattr(policies.get(boundary_arn), "document", None)] + ) + if boundary_status is False: + # The boundary provably does not grant *:*: the intersection cannot be + # administrative regardless of the identity policies. + return False + if boundary_status is None: + return None + return identity_status + + +def _session_is_scoped(profile, policies) -> bool | None: + """Return whether session policies restrict permissions, or None when unknown. + + AWS evaluates the inline ``sessionPolicy`` and every ``managedPolicyArns`` + entry together as a single session-policy category, so the complete set is + merged into one evaluation: the session is scoped only when at least one + session policy exists and the set does not grant ``*:*``. Managed entries + are resolved through the collected IAM policies. An invalid inline policy + or an unresolved managed policy does not prove that the session is + restricted and propagates as None. + + Args: + profile: A ``rolesanywhere_service.Profile``. + policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client. + """ + if not profile.session_policy and not profile.managed_policy_arns: + return False + + documents = [] + if profile.session_policy: + try: + documents.append(json.loads(profile.session_policy)) + except (ValueError, TypeError): + return None + for arn in profile.managed_policy_arns or []: + if arn.endswith(ADMIN_POLICY_ARN_SUFFIX): + documents.append({"Statement": [_ADMIN_STATEMENT]}) + else: + documents.append(getattr(policies.get(arn), "document", None)) + + grants_full_access = _full_access_status(documents) + return None if grants_full_access is None else not grants_full_access + + +class rolesanywhere_profile_restricts_session_permissions(Check): + """Flag Roles Anywhere profiles that vend an unscoped session on a privileged role. + + A Roles Anywhere profile that does not restrict the session with an inline + ``sessionPolicy`` or ``managedPolicyArns`` vends temporary credentials + carrying the full permissions of every role it references. This is only a + real risk when a referenced role is itself administrative: any certificate + accepted by the trust anchor then wields administrative permissions, turning + the profile into a durable privileged-access path that surviving key rotation + does not remove. Profiles that scope the session, whose referenced roles were + proven not administrative, or that are disabled are reported as PASS. When + session scoping or role permissions cannot be evaluated (unresolved or + invalid policy documents, condition-guarded grants, unknown roles), the + report is MANUAL rather than a proven outcome. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate session-permission scoping for Roles Anywhere profiles. + + Returns: + list[Check_Report_AWS]: One report per Roles Anywhere profile. FAIL + for enabled, unscoped profiles that reference a proven administrative + role; MANUAL when session scoping or role permissions could not be + evaluated; PASS for scoped profiles, profiles whose roles were proven + not administrative, and disabled profiles. + """ + findings = [] + roles_by_arn = {role.arn: role for role in iam_client.roles} + for profile in rolesanywhere_client.profiles.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=profile) + role_statuses = { + arn: ( + _role_is_privileged(roles_by_arn[arn], iam_client.policies) + if arn in roles_by_arn + else None + ) + for arn in profile.role_arns + } + privileged_role_arns = [ + arn for arn, status in role_statuses.items() if status is True + ] + unknown_role_arns = [ + arn for arn, status in role_statuses.items() if status is None + ] + session_scoped = _session_is_scoped(profile, iam_client.policies) + + if not profile.enabled: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} is disabled and " + "cannot vend session credentials." + ) + elif session_scoped is True: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} restricts vended " + "session permissions with a session policy or managed policies." + ) + elif session_scoped is None: + report.status = "MANUAL" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} session scoping " + "could not be evaluated because an inline or managed session " + "policy was invalid or unresolved." + ) + elif privileged_role_arns: + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} does not scope down " + "sessions and references administrative role(s) " + f"{', '.join(privileged_role_arns)}; certificates authenticated " + "through it inherit administrative permissions, enabling durable " + "privileged access." + ) + elif unknown_role_arns: + report.status = "MANUAL" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} does not scope down " + "sessions, and the effective permissions of referenced role(s) " + f"{', '.join(unknown_role_arns)} could not be evaluated." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} does not scope down " + "sessions, but no referenced role was identified as " + "administrative; scoping the session is recommended as " + "defense-in-depth." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py index 46dca281d6..aa53352b4a 100644 --- a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py @@ -11,7 +11,9 @@ class RolesAnywhere(AWSService): def __init__(self, provider): super().__init__(__class__.__name__, provider) self.trust_anchors = {} + self.profiles = {} self.__threading_call__(self._list_trust_anchors) + self.__threading_call__(self._list_profiles) def _list_trust_anchors(self, regional_client): logger.info("RolesAnywhere - Listing Trust Anchors...") @@ -52,6 +54,53 @@ class RolesAnywhere(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_profiles(self, regional_client): + """List and cache IAM Roles Anywhere profiles for one AWS Region. + + Args: + regional_client: Roles Anywhere client for the audited Region. + """ + logger.info("RolesAnywhere - Listing Profiles...") + try: + paginator = regional_client.get_paginator("list_profiles") + for page in paginator.paginate(): + for profile in page.get("profiles", []): + arn = profile.get("profileArn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + tags = [] + try: + tags = regional_client.list_tags_for_resource( + resourceArn=arn + ).get("tags", []) + except Exception as error: + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.profiles[arn] = Profile( + arn=arn, + id=profile.get("profileId", ""), + name=profile.get("name", ""), + region=regional_client.region, + enabled=profile.get("enabled", False), + role_arns=profile.get("roleArns", []) or [], + session_policy=profile.get("sessionPolicy", "") or "", + managed_policy_arns=profile.get("managedPolicyArns", []) or [], + duration_seconds=profile.get("durationSeconds", 0) or 0, + accept_role_session_name=profile.get( + "acceptRoleSessionName", False + ), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class TrustAnchor(BaseModel): arn: str @@ -62,3 +111,19 @@ class TrustAnchor(BaseModel): source_type: str = "" acm_pca_arn: str = "" tags: List[Dict[str, str]] = Field(default_factory=list) + + +class Profile(BaseModel): + """Represent an IAM Roles Anywhere profile.""" + + arn: str + id: str + name: str + region: str + enabled: bool = False + role_arns: List[str] = Field(default_factory=list) + session_policy: str = "" + managed_policy_arns: List[str] = Field(default_factory=list) + duration_seconds: int = 0 + accept_role_session_name: bool = False + tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/gcp/services/iam/iam_service.py b/prowler/providers/gcp/services/iam/iam_service.py index 987a86068c..cc2deddefa 100644 --- a/prowler/providers/gcp/services/iam/iam_service.py +++ b/prowler/providers/gcp/services/iam/iam_service.py @@ -17,6 +17,8 @@ class IAM(GCPService): self.service_accounts = [] self._get_service_accounts() self._get_service_accounts_keys() + self.workload_identity_pool_providers = [] + self._get_workload_identity_pool_providers() def _get_service_accounts(self): for project_id in self.project_ids: @@ -87,6 +89,94 @@ class IAM(GCPService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_workload_identity_pool_providers(self): + for project_id in self.project_ids: + try: + pools_request = ( + self.client.projects() + .locations() + .workloadIdentityPools() + .list(parent=f"projects/{project_id}/locations/global") + ) + while pools_request is not None: + pools_response = pools_request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for pool in pools_response.get("workloadIdentityPools", []): + self._get_providers_for_pool(project_id, pool) + pools_request = ( + self.client.projects() + .locations() + .workloadIdentityPools() + .list_next( + previous_request=pools_request, + previous_response=pools_response, + ) + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_providers_for_pool(self, project_id, pool): + try: + pool_name = pool.get("name", "") + pool_id = pool_name.split("/")[-1] + # A provider can remain ACTIVE while its parent pool is disabled or + # soft-deleted; a disabled pool cannot vend credentials, so the + # pool's effective availability must travel with the provider. + pool_disabled = ( + pool.get("disabled", False) or pool.get("state", "ACTIVE") != "ACTIVE" + ) + request = ( + self.client.projects() + .locations() + .workloadIdentityPools() + .providers() + .list(parent=pool_name) + ) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for provider in response.get("workloadIdentityPoolProviders", []): + provider_type = next( + ( + key + for key in ("oidc", "aws", "saml", "x509") + if key in provider + ), + "", + ) + self.workload_identity_pool_providers.append( + WorkloadIdentityPoolProvider( + name=provider.get("name", ""), + id=provider.get("name", "").split("/")[-1], + pool_id=pool_id, + pool_disabled=pool_disabled, + project_id=project_id, + state=provider.get("state", ""), + disabled=provider.get("disabled", False), + attribute_condition=provider.get("attributeCondition", ""), + attribute_mapping=provider.get("attributeMapping", {}) + or {}, + provider_type=provider_type, + issuer_uri=(provider.get("oidc", {}) or {}).get( + "issuerUri", "" + ), + display_name=provider.get("displayName", ""), + ) + ) + request = ( + self.client.projects() + .locations() + .workloadIdentityPools() + .providers() + .list_next(previous_request=request, previous_response=response) + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Key(BaseModel): name: str @@ -106,6 +196,25 @@ class ServiceAccount(BaseModel): disabled: bool = False +class WorkloadIdentityPoolProvider(BaseModel): + """Represent a GCP Workload Identity Federation pool provider.""" + + name: str + id: str + pool_id: str + # True when the parent pool is disabled or not ACTIVE; such a pool cannot + # vend credentials regardless of the provider's own state. + pool_disabled: bool = False + project_id: str + state: str = "" + disabled: bool = False + attribute_condition: str = "" + attribute_mapping: dict = {} + provider_type: str = "" + issuer_uri: str = "" + display_name: str = "" + + class AccessApproval(GCPService): def __init__(self, provider: GcpProvider): super().__init__(__class__.__name__, provider) diff --git a/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/__init__.py b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.metadata.json b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.metadata.json new file mode 100644 index 0000000000..5caa3cb774 --- /dev/null +++ b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "iam_workload_identity_pool_provider_attribute_condition", + "CheckTitle": "Workload Identity Federation providers trusting a multi-tenant issuer enforce an attribute condition", + "CheckType": [], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "iam.googleapis.com/WorkloadIdentityPoolProvider", + "Description": "**Workload Identity Federation providers** define an `attributeCondition` (CEL) restricting which external identities may impersonate Google Cloud principals. When a provider trusts a **multi-tenant issuer** (GitHub Actions, GitLab.com and other shared issuers), omitting the condition trusts every identity that issuer can mint. Providers trusting a dedicated single-tenant issuer are not flagged.", + "Risk": "A provider trusting a multi-tenant issuer without an attribute condition accepts any external identity from that issuer - for example any GitHub repository when the issuer is GitHub Actions. An attacker controlling any tenant on that platform can authenticate through the provider and exchange tokens for federated credentials. Because no key is stored, this access survives credential rotation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/iam/docs/workload-identity-federation", + "https://cloud.google.com/iam/docs/workload-identity-federation#mapping", + "https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation" + ], + "Remediation": { + "Code": { + "CLI": "gcloud iam workload-identity-pools providers update- --location=global --workload-identity-pool= --attribute-condition=\"\" # use the update subcommand matching the provider type", + "NativeIaC": "", + "Other": "1. In the Google Cloud console, go to IAM & Admin > Workload Identity Federation\n2. Open the affected pool and provider\n3. Set an attribute condition (CEL) restricting the allowed external identities, using an assertion that fits the provider type: OIDC by subject or claim (assertion.sub), SAML by NameID or attribute (assertion.subject or assertion.attributes[...]), AWS by account/role (assertion.account or assertion.arn), X.509 by certificate subject\n4. For OIDC providers, also restrict allowedAudiences to your own audience\n5. Review providers regularly for entries you did not create", + "Terraform": "```hcl\nresource \"google_iam_workload_identity_pool_provider\" \"example\" {\n workload_identity_pool_id = \"my-pool\"\n workload_identity_pool_provider_id = \"my-provider\"\n attribute_condition = \"assertion.repository_owner == 'my-org'\" # FIX: restrict trusted identities\n\n # Declare exactly one of oidc {}, aws {}, saml {}, or x509 {} to match the provider type, e.g.:\n oidc {\n issuer_uri = \"https://token.actions.githubusercontent.com\"\n allowed_audiences = [\"https://my-audience.example.com\"]\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set an attribute condition on any active Workload Identity Federation provider that trusts a multi-tenant issuer so only the intended external identities can impersonate Google Cloud principals, and pair OIDC providers with a restricted audience. Providers trusting a dedicated single-tenant issuer should still add a condition as defense-in-depth. Audit pools and providers regularly for unexpected entries.", + "Url": "https://hub.prowler.com/check/iam_workload_identity_pool_provider_attribute_condition" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Only active providers that trust a known multi-tenant issuer (GitHub Actions, GitLab.com, Google, HCP Terraform) are failed when they omit an attribute condition; providers trusting a dedicated single-tenant issuer, AWS/SAML/X.509 providers, and disabled or non-ACTIVE providers are reported as PASS. This check verifies that an attribute condition is present; it does not evaluate whether the condition's expression is sufficiently restrictive." +} diff --git a/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.py b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.py new file mode 100644 index 0000000000..79577e888f --- /dev/null +++ b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.py @@ -0,0 +1,107 @@ +from urllib.parse import urlparse + +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.iam.iam_client import iam_client + +# OIDC issuers whose tokens are minted for many independent tenants (any GitHub +# repository, any GitLab project, any Google account, ...). A provider that +# trusts one of these without an ``attributeCondition`` accepts identities +# outside the operator's control, so omitting the condition genuinely expands +# trust. A dedicated, single-tenant issuer only vends tokens to the operator's +# own workloads, so an attribute condition there is defense-in-depth rather than +# a requirement (see Google's guidance for GitHub and other shared issuers). +MULTI_TENANT_OIDC_ISSUER_HOSTS = { + "token.actions.githubusercontent.com", # GitHub Actions (any repository) + "gitlab.com", # GitLab.com SaaS (any project) + "accounts.google.com", # any Google account + "app.terraform.io", # HCP Terraform (any organization) +} + + +def _is_multi_tenant_issuer(issuer_uri: str) -> bool: + """Return True when the OIDC issuer is a known multi-tenant/shared issuer.""" + if not issuer_uri: + return False + # hostname lowercases and strips port/userinfo (gitlab.com:443, user@host); + # fall back to the raw string for bare hosts without a scheme. + host = urlparse(issuer_uri).hostname or issuer_uri.lower() + return host in MULTI_TENANT_OIDC_ISSUER_HOSTS + + +class iam_workload_identity_pool_provider_attribute_condition(Check): + """Ensure WIF providers trusting a multi-tenant issuer enforce an attribute condition. + + A workload identity pool provider that trusts a multi-tenant issuer (GitHub + Actions, GitLab.com, ...) without an ``attributeCondition`` accepts every + external identity that issuer can mint. An attacker controlling any tenant on + that platform can then authenticate through the provider and exchange tokens + for federated credentials, surviving credential rotation. Providers that + enforce an attribute condition, that trust a dedicated single-tenant issuer, + that are not OIDC-based, that are disabled/inactive, or whose parent pool is + disabled are reported as PASS. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Evaluate the attribute condition of each Workload Identity provider. + + Returns: + list[Check_Report_GCP]: One report per workload identity pool + provider. FAIL for active providers that trust a multi-tenant issuer + without an attribute condition; PASS for providers that enforce one, + trust a dedicated issuer, are not OIDC-based, or are + disabled/inactive. + """ + findings = [] + for provider in iam_client.workload_identity_pool_providers: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=provider, + resource_id=provider.name, + resource_name=provider.display_name or provider.id, + location="global", + ) + if provider.pool_disabled: + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} belongs " + f"to the disabled pool {provider.pool_id}, which cannot vend " + "credentials." + ) + elif provider.disabled or provider.state != "ACTIVE": + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} is not active and cannot vend credentials." + ) + elif provider.attribute_condition: + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} enforces an attribute condition." + ) + elif _is_multi_tenant_issuer(provider.issuer_uri): + report.status = "FAIL" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} trusts the multi-tenant issuer " + f"{provider.issuer_uri} without an attribute condition, so any " + "identity from that issuer can authenticate through this provider." + ) + elif provider.provider_type != "oidc": + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} is not an OIDC provider trusting a " + "multi-tenant issuer; an attribute condition is recommended as " + "defense-in-depth but not required." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} trusts a dedicated issuer; an attribute " + "condition is recommended as defense-in-depth but not required." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited.py b/prowler/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited.py index 15e5b4b0aa..8a96a73f84 100644 --- a/prowler/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited.py +++ b/prowler/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited.py @@ -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." diff --git a/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/__init__.py b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.metadata.json b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.metadata.json new file mode 100644 index 0000000000..a774ceee96 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.metadata.json @@ -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." +} diff --git a/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.py b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.py new file mode 100644 index 0000000000..6feda3543a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/__init__.py b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.metadata.json b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.metadata.json new file mode 100644 index 0000000000..9c8e3a42a0 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.metadata.json @@ -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." +} diff --git a/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.py b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.py new file mode 100644 index 0000000000..56268818bf --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/__init__.py b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.metadata.json b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.metadata.json new file mode 100644 index 0000000000..77fbc3b7cf --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.metadata.json @@ -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": "" +} diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.py b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.py new file mode 100644 index 0000000000..98264d8099 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/__init__.py b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.metadata.json b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.metadata.json new file mode 100644 index 0000000000..b57c9a0431 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.py b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.py new file mode 100644 index 0000000000..3e962f4582 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.metadata.json new file mode 100644 index 0000000000..e18bd0f60d --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.metadata.json @@ -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": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.py new file mode 100644 index 0000000000..ef2df98f8e --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.metadata.json new file mode 100644 index 0000000000..3eb32f2730 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.metadata.json @@ -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": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.py new file mode 100644 index 0000000000..99a2328eb0 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.metadata.json new file mode 100644 index 0000000000..44acb444d8 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.metadata.json @@ -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": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.py new file mode 100644 index 0000000000..607c3a8c8c --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.metadata.json new file mode 100644 index 0000000000..2a1c1ec562 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.metadata.json @@ -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": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.py new file mode 100644 index 0000000000..6584e302b3 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.metadata.json new file mode 100644 index 0000000000..1dbede3d19 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.metadata.json @@ -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": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.py new file mode 100644 index 0000000000..4eb7610168 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.py @@ -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 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.metadata.json new file mode 100644 index 0000000000..2772ebd6ea --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_trusted_named_location_exists", + "CheckTitle": "A trusted IP-range named location is defined", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Conditional Access **named locations** let an organization define **trusted** IP ranges that can be referenced by Conditional Access policies. At least one **IP-range** named location should be marked as **Trusted** and have at least one IP range defined.", + "Risk": "Without a trusted named location, **Conditional Access** policies cannot differentiate corporate networks from untrusted networks, limiting the ability to build location-aware controls such as blocking access from untrusted locations.", + "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** > **Named locations**\n3. Create a **new IP ranges location**, add the organization's public IP ranges\n4. Mark the location as **Trusted**\n5. Click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Define at least one trusted IP-range named location representing the organization's corporate networks so it can be referenced by location-aware Conditional Access policies.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_trusted_named_location_exists" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.py b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.py new file mode 100644 index 0000000000..bde5805b29 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.py @@ -0,0 +1,46 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_conditional_access_trusted_named_location_exists(Check): + """Check if at least one trusted IP-range named location is defined. + + Conditional Access named locations let an organization define trusted IP ranges. + At least one IP-range named location should be marked as trusted and have at + least one IP range defined. + + - PASS: A trusted IP-range named location with at least one IP range exists. + - FAIL: No trusted IP-range named location with an IP range is defined. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the trusted named location check. + + Returns: + A list containing the trusted named location evaluation report. + """ + findings = [] + for location in entra_client.named_locations: + location_name = location.display_name or location.id + report = CheckReportM365( + metadata=self.metadata(), + resource=location, + resource_name=location.display_name or "Named Location", + resource_id=location.id, + ) + report.status = "FAIL" + report.status_extended = f"Named location '{location_name}' is not a trusted IP-range location with at least one IP range." + + if ( + location.is_ip_location + and location.is_trusted + and location.ip_ranges_count >= 1 + ): + report.status = "PASS" + report.status_extended = ( + f"Trusted IP-range named location '{location_name}' " + f"is defined with {location.ip_ranges_count} IP range(s)." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/__init__.py b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.metadata.json b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.metadata.json new file mode 100644 index 0000000000..f2fdcdc957 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_idle_session_timeout_configured", + "CheckTitle": "Idle session timeout of 3 hours or less is configured", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "An activity-based timeout policy should sign out inactive users from Microsoft 365 web apps after a period of inactivity. The web session idle timeout (**WebSessionIdleTimeout**) should be set to **3 hours or less** so that unattended sessions are terminated.", + "Risk": "Without an idle session timeout, an unattended and unlocked session on a shared or public device remains authenticated indefinitely, allowing anyone with physical access to act as the signed-in user.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/idle-session-timeout-web-apps" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft 365 admin center at https://admin.microsoft.com/\n2. Go to **Settings** > **Org settings** > **Security & Privacy** > **Idle session timeout**\n3. Turn on the setting and set the period of inactivity to **3 hours** or less\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure an idle session timeout of 3 hours or less so inactive users are automatically signed out of Microsoft 365 web apps.", + "Url": "https://hub.prowler.com/check/entra_idle_session_timeout_configured" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.py b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.py new file mode 100644 index 0000000000..6cfaa7e4f1 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.py @@ -0,0 +1,70 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# CIS recommends an idle session timeout of 3 hours or less. +MAX_IDLE_TIMEOUT_SECONDS = 3 * 60 * 60 + + +class entra_idle_session_timeout_configured(Check): + """Check if an idle session timeout of 3 hours or less is configured. + + An activity-based timeout policy should sign out inactive users from Microsoft + 365 web apps after a period of inactivity. The web session idle timeout should be + set to 3 hours or less. + + - PASS: An activity-based timeout policy enforces an idle timeout of 3 hours or + less. + - FAIL: No activity-based timeout policy enforces an idle timeout of 3 hours or + less. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the idle session timeout policy check. + + Returns: + A list of reports for the collected activity-based timeout policies. + """ + findings = [] + policies = entra_client.activity_based_timeout_policies + + if not policies: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Activity Based Timeout Policies", + resource_id="activityBasedTimeoutPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No activity-based timeout policy enforces an idle session timeout of " + "3 hours or less." + ) + findings.append(report) + return findings + + for policy in policies: + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name or "Activity Based Timeout Policy", + resource_id=policy.id, + ) + timeout = policy.web_session_idle_timeout_seconds + if timeout is not None and timeout <= MAX_IDLE_TIMEOUT_SECONDS: + report.status = "PASS" + report.status_extended = ( + f"Activity-based timeout policy '{policy.display_name or policy.id}' " + f"enforces an idle session timeout of {timeout // 60} minutes and " + f"{timeout % 60} seconds." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Activity-based timeout policy '{policy.display_name or policy.id}' " + "does not enforce an idle session timeout of 3 hours or less." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/__init__.py b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.metadata.json b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.metadata.json new file mode 100644 index 0000000000..a2c8cded69 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_pim_global_administrator_approval_required", + "CheckTitle": "PIM requires approval to activate the Global Administrator role", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Privileged Identity Management (PIM) should require **approval to activate** the **Global Administrator** role, with at least one approver configured. This adds a human authorization step before the most privileged role in the tenant can be used. Requires a Microsoft Entra ID P2 license.", + "Risk": "Without approval to activate **Global Administrator**, an eligible or compromised account can self-activate the highest-privilege role without oversight, enabling immediate tenant-wide compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings" + ], + "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** > **Roles**\n3. Select **Global Administrator** > **Role settings**\n4. Set **Require approval to activate** to **Yes** and add at least one approver\n5. Save the settings", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require approval to activate the Global Administrator role in PIM and configure at least one approver so activations are authorized before use.", + "Url": "https://hub.prowler.com/check/entra_pim_global_administrator_approval_required" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requires Microsoft Entra ID P2. If PIM role settings are unavailable (no P2), the check returns no findings for this role." +} diff --git a/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.py b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.py new file mode 100644 index 0000000000..91c8347659 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.py @@ -0,0 +1,62 @@ +from typing import List + +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 ( + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, +) + + +class entra_pim_global_administrator_approval_required(Check): + """Check if PIM requires approval to activate the Global Administrator role. + + Privileged Identity Management (PIM) should require approval to activate the + Global Administrator role, with at least one approver configured. + + - PASS: Approval is required to activate Global Administrator and approvers exist. + - FAIL: Approval is not required or no approvers are configured. + - No findings: PIM settings are unavailable. + """ + + def execute(self) -> List[CheckReportM365]: + """Evaluate PIM approval settings for the Global Administrator role. + + Reports whether Privileged Identity Management requires approval to activate + the Global Administrator role and whether at least one approver is configured. + + Returns: + List[CheckReportM365]: A single report for the Global Administrator PIM + role settings, or an empty list when the settings are absent. + """ + findings = [] + setting = entra_client.pim_role_approval_settings.get( + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID + ) + if not setting: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=setting, + resource_name="Global Administrator PIM Role Settings", + resource_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = ( + "PIM does not require approval to activate the Global Administrator role." + ) + + if setting.is_approval_required and not setting.has_approvers: + report.status_extended = ( + "PIM requires approval to activate the Global Administrator role but " + "no approvers are configured." + ) + elif setting.is_approval_required and setting.has_approvers: + report.status = "PASS" + report.status_extended = ( + "PIM requires approval to activate the Global Administrator role and " + "has approvers configured." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/__init__.py b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.metadata.json b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.metadata.json new file mode 100644 index 0000000000..422f5468e9 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_pim_privileged_role_administrator_approval_required", + "CheckTitle": "PIM requires approval to activate the Privileged Role Administrator role", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Privileged Identity Management (PIM) should require **approval to activate** the **Privileged Role Administrator** role, with at least one approver configured. This role can manage role assignments, so its activation should require human authorization. Requires a Microsoft Entra ID P2 license.", + "Risk": "Without approval to activate **Privileged Role Administrator**, an eligible or compromised account can self-activate a role that grants control over role assignments, enabling privilege escalation across the tenant.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings" + ], + "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** > **Roles**\n3. Select **Privileged Role Administrator** > **Role settings**\n4. Set **Require approval to activate** to **Yes** and add at least one approver\n5. Save the settings", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require approval to activate the Privileged Role Administrator role in PIM and configure at least one approver so activations are authorized before use.", + "Url": "https://hub.prowler.com/check/entra_pim_privileged_role_administrator_approval_required" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requires Microsoft Entra ID P2. If PIM role settings are unavailable (no P2), the check returns no findings for this role." +} diff --git a/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.py b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.py new file mode 100644 index 0000000000..9ed5b57b18 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.py @@ -0,0 +1,65 @@ +from typing import List + +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 ( + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, +) + + +class entra_pim_privileged_role_administrator_approval_required(Check): + """Check if PIM requires approval to activate the Privileged Role Administrator role. + + Privileged Identity Management (PIM) should require approval to activate the + Privileged Role Administrator role, with at least one approver configured. + + - PASS: Approval is required to activate Privileged Role Administrator and + approvers exist. + - FAIL: Approval is not required or no approvers are configured. + """ + + def execute(self) -> List[CheckReportM365]: + """Evaluate PIM approval settings for the Privileged Role Administrator role. + + Reports whether Privileged Identity Management requires approval to activate + the Privileged Role Administrator role and whether at least one approver is + configured. + + Returns: + List[CheckReportM365]: A single report for the Privileged Role + Administrator PIM role settings, or an empty list when the settings are + absent. + """ + findings = [] + setting = entra_client.pim_role_approval_settings.get( + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID + ) + if not setting: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=setting, + resource_name="Privileged Role Administrator PIM Role Settings", + resource_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = ( + "PIM does not require approval to activate the Privileged Role " + "Administrator role." + ) + + if setting.is_approval_required and not setting.has_approvers: + report.status_extended = ( + "PIM requires approval to activate the Privileged Role Administrator " + "role but no approvers are configured." + ) + elif setting.is_approval_required and setting.has_approvers: + report.status = "PASS" + report.status_extended = ( + "PIM requires approval to activate the Privileged Role Administrator " + "role and has approvers configured." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index de07166afd..c839216a76 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -105,6 +105,11 @@ class Entra(M365Service): self._get_device_registration_policy(), self._get_directory_settings(), self._get_b2b_collaboration_policy(), + self._get_activity_based_timeout_policies(), + self._get_named_locations(), + self._get_authentication_methods_policy_settings(), + self._get_pim_role_approval_settings(), + self._get_access_review_definitions(), ) ) @@ -130,6 +135,17 @@ class Entra(M365Service): ) self.directory_settings: Dict[str, Dict[str, str]] = attributes[14] self.b2b_collaboration_policy: Optional[B2BCollaborationPolicy] = attributes[15] + self.activity_based_timeout_policies: List[ActivityBasedTimeoutPolicy] = ( + attributes[16] + ) + self.named_locations: List[NamedLocation] = attributes[17] + self.authentication_methods_policy_settings: Optional[ + AuthenticationMethodsPolicySettings + ] = attributes[18] + self.pim_role_approval_settings: Dict[str, PimRoleApprovalSetting] = attributes[ + 19 + ] + self.access_review_definitions: List[AccessReviewDefinition] = attributes[20] self.user_accounts_status = {} # Resolve directory-object identifiers referenced by Conditional Access @@ -394,6 +410,24 @@ class Entra(M365Service): authentication_flows=self._parse_authentication_flows( raw_auth_flows_map.get(policy.id) ), + locations=LocationsCondition( + include_locations=list( + getattr( + getattr(policy.conditions, "locations", None), + "include_locations", + [], + ) + or [] + ), + exclude_locations=list( + getattr( + getattr(policy.conditions, "locations", None), + "exclude_locations", + [], + ) + or [] + ), + ), device_conditions=DeviceConditions( device_filter_mode=( DeviceFilterMode( @@ -524,6 +558,19 @@ class Entra(M365Service): else False ), ), + secure_sign_in_session_enabled=bool( + getattr( + getattr( + policy.session_controls, + "secure_sign_in_session", + None, + ), + "is_enabled", + False, + ) + if policy.session_controls + else False + ), ), state=ConditionalAccessPolicyState( getattr(policy, "state", "disabled") @@ -1247,6 +1294,167 @@ OAuthAppInfo ) return device_registration_policy + async def _get_pim_role_approval_settings(self): + """Retrieve PIM approval-to-activate settings per directory role. + + Fetches ``policies/roleManagementPolicyAssignments`` for directory roles with + their expanded policy rules, and extracts, per role definition, whether + approval is required to activate and whether approvers are configured. + + Returns: + Dict[str, PimRoleApprovalSetting]: Keyed by role definition (template) id. + """ + logger.info("Entra - Getting PIM role approval settings...") + settings: Dict[str, PimRoleApprovalSetting] = {} + try: + url = ( + "https://graph.microsoft.com/v1.0/policies/" + "roleManagementPolicyAssignments?$filter=scopeId%20eq%20'/'%20and%20" + "scopeType%20eq%20'DirectoryRole'&$expand=policy($expand=rules)" + ) + request_info = self.client.policies.with_url( + url + ).to_get_request_information() + assignments = [] + while True: + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if not response: + break + data = json.loads(response) + page = data.get("value", []) or [] + if not page: + break + assignments.extend(page) + next_link = data.get("@odata.nextLink") or data.get("nextLink") + if not next_link: + break + request_info = self.client.policies.with_url( + next_link + ).to_get_request_information() + for assignment in assignments: + role_id = assignment.get("roleDefinitionId") + if not role_id: + continue + rules = (assignment.get("policy", {}) or {}).get("rules", []) or [] + is_approval_required = False + has_approvers = False + for rule in rules: + if rule.get("id") == "Approval_EndUser_Assignment": + setting = rule.get("setting", {}) or {} + is_approval_required = bool( + setting.get("isApprovalRequired", False) + ) + for stage in setting.get("approvalStages", []) or []: + if stage.get("primaryApprovers"): + has_approvers = True + break + settings[role_id] = PimRoleApprovalSetting( + role_definition_id=role_id, + is_approval_required=is_approval_required, + has_approvers=has_approvers, + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return settings + + async def _get_access_review_definitions(self): + """Retrieve access review definitions from Microsoft Entra ID Governance. + + Fetches ``identityGovernance/accessReviews/definitions`` and captures the + status and scope query of each definition for the access-review checks. + + Returns: + List[AccessReviewDefinition]: The parsed access review definitions. + """ + logger.info("Entra - Getting access review definitions...") + definitions = [] + try: + url = ( + "https://graph.microsoft.com/v1.0/identityGovernance/" + "accessReviews/definitions" + ) + request_info = self.client.identity_governance.with_url( + url + ).to_get_request_information() + raw_definitions = [] + while True: + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if not response: + break + data = json.loads(response) + page = data.get("value", []) or [] + if not page: + break + raw_definitions.extend(page) + next_link = data.get("@odata.nextLink") or data.get("nextLink") + if not next_link: + break + request_info = self.client.identity_governance.with_url( + next_link + ).to_get_request_information() + for definition in raw_definitions: + scope = definition.get("scope", {}) or {} + settings = definition.get("settings", {}) or {} + recurrence = settings.get("recurrence", {}) or {} + recurrence_pattern = recurrence.get("pattern", {}) or {} + recurrence_range = recurrence.get("range", {}) or {} + stage_settings = definition.get("stageSettings") + if stage_settings is not None: + has_primary_reviewers = bool(stage_settings) and all( + bool(stage.get("reviewers", []) or []) + for stage in stage_settings + ) + else: + has_primary_reviewers = bool(definition.get("reviewers", []) or []) + definitions.append( + AccessReviewDefinition( + id=definition.get("id", ""), + display_name=definition.get("displayName"), + status=definition.get("status"), + scope_query=str(scope.get("query", "")), + resource_scope_queries=[ + str(resource_scope.get("query", "")) + for resource_scope in ( + scope.get("resourceScopes", []) or [] + ) + ], + principal_scope_queries=[ + str(principal_scope.get("query", "")) + for principal_scope in ( + scope.get("principalScopes", []) or [] + ) + ], + default_decision=settings.get("defaultDecision"), + default_decision_enabled=bool( + settings.get("defaultDecisionEnabled", False) + ), + auto_apply_enabled=bool( + settings.get("autoApplyDecisionsEnabled", False) + ), + mail_notifications_enabled=bool( + settings.get("mailNotificationsEnabled", False) + ), + reminders_enabled=bool( + settings.get("reminderNotificationsEnabled", False) + ), + duration_in_days=settings.get("instanceDurationInDays"), + recurrence_pattern_type=recurrence_pattern.get("type"), + recurrence_range_type=recurrence_range.get("type"), + has_primary_reviewers=has_primary_reviewers, + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return definitions + async def _get_b2b_collaboration_policy(self): """Retrieve the legacy B2B collaboration (invitation domains) policy. @@ -1308,6 +1516,200 @@ OAuthAppInfo ) return b2b_policy + async def _get_named_locations(self): + """Retrieve Conditional Access named locations from Microsoft Entra. + + Fetches ``identity/conditionalAccess/namedLocations`` as raw JSON to handle + the polymorphic ipNamedLocation / countryNamedLocation types and extract + their trust and IP-range attributes. + + Returns: + List[NamedLocation]: The parsed named locations. + """ + logger.info("Entra - Getting named locations...") + named_locations = [] + try: + request_info = ( + self.client.identity.conditional_access.named_locations.to_get_request_information() + ) + raw_locations = [] + while True: + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if not response: + break + data = json.loads(response) + page = data.get("value", []) or [] + raw_locations.extend(page) + next_link = data.get("@odata.nextLink") or data.get("nextLink") + if not next_link: + break + request_info = ( + self.client.identity.conditional_access.named_locations.with_url( + next_link + ).to_get_request_information() + ) + for location in raw_locations: + odata_type = location.get("@odata.type", "") + ip_ranges = location.get("ipRanges", []) or [] + named_locations.append( + NamedLocation( + id=location.get("id", ""), + display_name=location.get("displayName"), + is_trusted=bool(location.get("isTrusted", False)), + is_ip_location="ipNamedLocation" in odata_type, + ip_ranges_count=len(ip_ranges), + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return named_locations + + @staticmethod + def _parse_timespan_to_seconds(value) -> "Optional[int]": + """Parse a .NET TimeSpan string (``[d.]hh:mm:ss[.fffffff]``) to seconds. + + Args: + value: The TimeSpan string (e.g. "03:00:00" or "1.00:00:00"). + + Returns: + The total number of seconds, or None if the value cannot be parsed. + """ + if not value or not isinstance(value, str): + return None + try: + days = 0 + remainder = value.strip() + if remainder.startswith("-"): + return None + head = remainder.split(":", 1)[0] + # A dot in the hours component denotes the days separator (d.hh). + if "." in head: + day_part, remainder = remainder.split(".", 1) + days = int(day_part) + hours, minutes, seconds = remainder.split(":") + # Seconds may carry fractional digits (ss.fffffff); truncate them. + seconds = seconds.split(".")[0] + hours = int(hours) + minutes = int(minutes) + seconds = int(seconds) + if days < 0 or not 0 <= hours <= 23: + return None + if not 0 <= minutes <= 59 or not 0 <= seconds <= 59: + return None + return days * 86400 + hours * 3600 + minutes * 60 + seconds + except (ValueError, AttributeError): + return None + + async def _get_activity_based_timeout_policies(self): + """Retrieve activity-based (idle session) timeout policies from Entra. + + Fetches ``policies/activityBasedTimeoutPolicies`` and parses each policy's + ``definition`` JSON to extract the ``WebSessionIdleTimeout`` for the idle + session timeout check. + + Returns: + List[ActivityBasedTimeoutPolicy]: The parsed timeout policies. + """ + logger.info("Entra - Getting activity based timeout policies...") + policies = [] + try: + policies_builder = self.client.policies.activity_based_timeout_policies + response = await policies_builder.get() + while response: + for policy in getattr(response, "value", []) or []: + idle_timeout_seconds = None + for definition in getattr(policy, "definition", []) or []: + try: + parsed = json.loads(definition) + except (TypeError, ValueError): + continue + app_policies = ( + parsed.get("ActivityBasedTimeoutPolicy", {}).get( + "ApplicationPolicies", [] + ) + or [] + ) + for app_policy in app_policies: + if app_policy.get("ApplicationId") != "default": + continue + seconds = self._parse_timespan_to_seconds( + app_policy.get("WebSessionIdleTimeout") + ) + if seconds is not None: + idle_timeout_seconds = seconds + break + if idle_timeout_seconds is not None: + break + policies.append( + ActivityBasedTimeoutPolicy( + id=getattr(policy, "id", ""), + display_name=getattr(policy, "display_name", None), + web_session_idle_timeout_seconds=idle_timeout_seconds, + ) + ) + next_link = getattr(response, "odata_next_link", None) + if not next_link: + break + response = await policies_builder.with_url(next_link).get() + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return policies + + async def _get_authentication_methods_policy_settings(self): + """Retrieve Microsoft Authenticator settings from the authentication methods policy. + + Fetches ``policies/authenticationMethodsPolicy`` from the v1.0 Graph endpoint + and extracts the Microsoft Authenticator state and its ``featureSettings`` + (app-information / location-information states). Parsed from raw JSON because + ``featureSettings`` lives on the derived Microsoft Authenticator configuration + type rather than the base configuration model. + + Returns: + Optional[AuthenticationMethodsPolicySettings]: Parsed settings, or None. + """ + logger.info("Entra - Getting authentication methods policy settings...") + settings = None + try: + builder = self.client.policies.authentication_methods_policy.with_url( + "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy" + ) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + authenticator = {} + for config in data.get("authenticationMethodConfigurations", []) or []: + if config.get("id") == "MicrosoftAuthenticator": + authenticator = config + break + feature_settings = authenticator.get("featureSettings", {}) or {} + settings = AuthenticationMethodsPolicySettings( + authenticator_state=authenticator.get("state"), + authenticator_display_app_information_state=( + feature_settings.get("displayAppInformationRequiredState", {}) + or {} + ).get("state"), + authenticator_display_location_information_state=( + feature_settings.get( + "displayLocationInformationRequiredState", {} + ) + or {} + ).get("state"), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return settings + async def _get_directory_settings(self): """Retrieve tenant directory (group) settings from Microsoft Entra. @@ -1967,6 +2369,13 @@ class AuthenticationFlows(BaseModel): transfer_methods: List[TransferMethod] = [] +class LocationsCondition(BaseModel): + """Model representing location conditions for Conditional Access policies.""" + + include_locations: List[str] = [] + exclude_locations: List[str] = [] + + class Conditions(BaseModel): """Model representing conditions for Conditional Access policies.""" @@ -1979,6 +2388,7 @@ class Conditions(BaseModel): platform_conditions: Optional[PlatformConditions] = None authentication_flows: Optional[AuthenticationFlows] = None device_conditions: Optional[DeviceConditions] = None + locations: Optional[LocationsCondition] = None class PersistentBrowser(BaseModel): @@ -2015,6 +2425,7 @@ class SessionControls(BaseModel): persistent_browser: PersistentBrowser sign_in_frequency: SignInFrequency application_enforced_restrictions: Optional[ApplicationEnforcedRestrictions] = None + secure_sign_in_session_enabled: bool = False class ConditionalAccessGrantControl(Enum): @@ -2076,6 +2487,10 @@ class AuthorizationPolicy(BaseModel): GROUP_UNIFIED_SETTINGS_TEMPLATE_ID = "62375ab9-6b52-47ed-826b-58e47e0e304b" PASSWORD_RULE_SETTINGS_TEMPLATE_ID = "5cf42378-d67d-4f36-ba46-e8b86229381d" +# Well-known directory role template IDs. +GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID = "62e90394-69f5-4237-9190-012177145e10" +PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID = "e8611ab8-c189-46e8-94e1-60213ab1f814" + class DeviceRegistrationMembershipType(str, Enum): """OData types for Entra device registration membership settings.""" @@ -2095,6 +2510,52 @@ class DeviceRegistrationPolicy(BaseModel): local_admin_password_enabled: Optional[bool] = None +class ActivityBasedTimeoutPolicy(BaseModel): + """Activity-based (idle session) timeout policy.""" + + id: str + display_name: Optional[str] = None + web_session_idle_timeout_seconds: Optional[int] = None + + +class NamedLocation(BaseModel): + """Conditional Access named location.""" + + id: str + display_name: Optional[str] = None + is_trusted: bool = False + is_ip_location: bool = False + ip_ranges_count: int = 0 + + +class PimRoleApprovalSetting(BaseModel): + """PIM approval-to-activate setting for a directory role.""" + + role_definition_id: str + is_approval_required: bool = False + has_approvers: bool = False + + +class AccessReviewDefinition(BaseModel): + """Access review definition (identityGovernance/accessReviews/definitions).""" + + id: str + display_name: Optional[str] = None + status: Optional[str] = None + scope_query: str = "" + resource_scope_queries: List[str] = [] + principal_scope_queries: List[str] = [] + default_decision: Optional[str] = None + default_decision_enabled: bool = False + auto_apply_enabled: bool = False + mail_notifications_enabled: bool = False + reminders_enabled: bool = False + duration_in_days: Optional[int] = None + recurrence_pattern_type: Optional[str] = None + recurrence_range_type: Optional[str] = None + has_primary_reviewers: bool = False + + class B2BCollaborationPolicy(BaseModel): """Legacy B2B collaboration (invitation domains) policy.""" @@ -2102,6 +2563,14 @@ class B2BCollaborationPolicy(BaseModel): allowed_domains: List[str] = [] +class AuthenticationMethodsPolicySettings(BaseModel): + """Microsoft Authenticator settings from the authentication methods policy.""" + + authenticator_state: Optional[str] = None + authenticator_display_app_information_state: Optional[str] = None + authenticator_display_location_information_state: Optional[str] = None + + class Organization(BaseModel): id: str name: str diff --git a/pyproject.toml b/pyproject.toml index 4d730c5364..a731fbe98c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] build-backend = "hatchling.build" -requires = ["hatchling"] +requires = ["hatchling==1.32.0"] [dependency-groups] dev = [ @@ -136,7 +136,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.39.0" +version = "5.40.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -392,7 +392,7 @@ constraint-dependencies = [ override-dependencies = [ "okta==3.4.2", # alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release. - "cryptography==50.0.0", + "cryptography==50.0.0" ] [tool.vulture] diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 1459c34b3c..1590d29742 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -1,6 +1,8 @@ +import re import sys from io import StringIO +import pytest from mock import MagicMock, patch from prowler.config.config import prowler_version, timestamp @@ -676,6 +678,192 @@ html_footer = """ """ +def _setup_aws_xss(provider, payload): + provider.identity.account = payload + provider.identity.profile = payload + provider.identity.audited_regions = [payload] + provider.identity.user_id = payload + provider.identity.identity_arn = payload + + +def _setup_azure_xss(provider, payload): + provider.identity.tenant_ids = [payload] + provider.identity.tenant_domain = payload + provider.identity.subscriptions = {payload: payload} + provider.identity.identity_type = payload + provider.identity.identity_id = payload + + +def _setup_gcp_xss(provider, payload): + provider.project_ids = [payload] + provider.session._service_account_email = payload + + +def _setup_kubernetes_xss(provider, payload): + provider.identity.cluster = payload + provider.identity.context = payload + + +def _setup_github_xss(provider, payload): + provider.identity = MagicMock(spec=["account_name", "account_email"]) + provider.identity.account_name = payload + provider.identity.account_email = payload + provider.auth_method = payload + + +def _setup_m365_xss(provider, payload): + provider.identity.tenant_domain = payload + provider.identity.identity_type = payload + provider.identity.identity_id = payload + provider.identity.user = payload + + +def _setup_nhn_xss(provider, payload): + provider.identity.tenant_domain = payload + provider.identity.identity_type = payload + provider.identity.identity_id = payload + + +def _setup_mongodbatlas_xss(provider, payload): + provider.identity.organization_name = payload + + +def _setup_iac_xss(provider, payload): + provider.scan_repository_url = payload + provider.scan_path = None + provider.auth_method = payload + + +def _setup_image_xss(provider, payload): + provider.registry = payload + provider.images = [payload] + provider.auth_method = payload + + +def _setup_llm_xss(provider, payload): + provider.model = payload + provider.plugins = [payload] + provider.max_concurrency = payload + provider.config_path = payload + + +def _setup_oraclecloud_xss(provider, payload): + provider.session.profile = payload + provider.identity.tenancy_name = payload + provider.identity.tenancy_id = payload + + +def _setup_stackit_xss(provider, payload): + provider.identity.project_id = payload + provider.identity.project_name = payload + provider.identity.audited_regions = {payload} + + +def _setup_cloudflare_xss(provider, payload): + account = MagicMock() + account.id = payload + provider.accounts = [account] + provider.session.api_token = "token" + provider.session.api_key = None + provider.session.api_email = None + provider.identity.email = payload + + +def _setup_alibabacloud_xss(provider, payload): + provider.identity.account_id = payload + provider.identity.account_name = payload + provider.identity.audited_regions = payload + provider.identity.identity_arn = payload + provider.identity.user_name = payload + + +def _setup_openstack_xss(provider, payload): + provider.identity.project_id = payload + provider.identity.project_name = payload + provider.identity.region_name = payload + provider.identity.username = payload + provider.identity.user_id = payload + + +def _setup_googleworkspace_xss(provider, payload): + provider.identity.domain = payload + provider.identity.customer_id = payload + provider.identity.delegated_user = payload + + +def _setup_e2enetworks_xss(provider, payload): + provider.identity.project_id = payload + provider.identity.locations = [payload] + + +def _setup_vercel_xss(provider, payload): + team = MagicMock() + team.name = payload + team.id = payload + provider.identity.team = team + provider.identity.email = payload + provider.identity.username = payload + + +def _setup_okta_xss(provider, payload): + provider.identity.org_domain = payload + provider.auth_method = payload + provider.identity.client_id = payload + + +def _setup_scaleway_xss(provider, payload): + provider.identity.organization_id = payload + provider.session.access_key = payload + provider.identity.bearer_type = payload + provider.identity.bearer_email = payload + provider.identity.bearer_id = payload + provider.session.default_region = payload + + +def _setup_linode_xss(provider, payload): + provider.identity.username = payload + provider.identity.email = payload + provider.identity.account_id = payload + + +def _setup_huaweicloud_xss(provider, payload): + provider.identity.account_id = payload + provider.identity.account_name = payload + provider.identity.profile = payload + provider.identity.regions = {payload} + provider.identity.domain_id = payload + provider.identity.user_id = payload + provider.identity.user_name = payload + provider.identity.identity_type = payload + + +PROVIDER_XSS_SETUPS = [ + ("aws", _setup_aws_xss), + ("azure", _setup_azure_xss), + ("gcp", _setup_gcp_xss), + ("kubernetes", _setup_kubernetes_xss), + ("github", _setup_github_xss), + ("m365", _setup_m365_xss), + ("nhn", _setup_nhn_xss), + ("mongodbatlas", _setup_mongodbatlas_xss), + ("iac", _setup_iac_xss), + ("image", _setup_image_xss), + ("llm", _setup_llm_xss), + ("oraclecloud", _setup_oraclecloud_xss), + ("stackit", _setup_stackit_xss), + ("cloudflare", _setup_cloudflare_xss), + ("alibabacloud", _setup_alibabacloud_xss), + ("openstack", _setup_openstack_xss), + ("googleworkspace", _setup_googleworkspace_xss), + ("e2enetworks", _setup_e2enetworks_xss), + ("vercel", _setup_vercel_xss), + ("okta", _setup_okta_xss), + ("scaleway", _setup_scaleway_xss), + ("linode", _setup_linode_xss), + ("huaweicloud", _setup_huaweicloud_xss), +] + + class TestHTML: def test_transform_fail_finding(self): findings = [ @@ -1100,6 +1288,103 @@ class TestHTML: assert "" not in summary assert "Delhi"><script>alert(1)</script>" in summary + @pytest.mark.parametrize( + "provider_type,setup_fn", + PROVIDER_XSS_SETUPS, + ids=[t for t, _ in PROVIDER_XSS_SETUPS], + ) + def test_get_assessment_summary_escapes_provider_identity( + self, provider_type, setup_fn + ): + """Every provider header must HTML-escape tenant-controlled identity fields.""" + payload = "" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = provider_type + setup_fn(provider, payload) + + summary = output.get_assessment_summary(provider) + + assert payload not in summary + assert "<script>alert(1)</script>" in summary + + def test_provider_xss_setups_covers_every_assessment_summary_method(self): + """Adding a new get__assessment_summary without a PROVIDER_XSS_SETUPS + entry must fail this test, so the escape guarantee cannot silently regress.""" + pattern = re.compile(r"^get_(.+)_assessment_summary$") + discovered = set() + for name in dir(HTML): + match = pattern.match(name) + if match: + discovered.add(match.group(1)) + + covered = {ptype for ptype, _ in PROVIDER_XSS_SETUPS} + + missing = discovered - covered + extra = covered - discovered + assert ( + not missing + ), f"providers without XSS coverage in PROVIDER_XSS_SETUPS: {sorted(missing)}" + assert ( + not extra + ), f"PROVIDER_XSS_SETUPS entries with no matching HTML method: {sorted(extra)}" + + def test_github_app_get_assessment_summary_escapes_app_identity(self): + """The GitHub App branch (elif hasattr app_id) must escape app_name/app_id/installations, + which the PAT setup does not exercise.""" + payload = "" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "github" + provider.identity = MagicMock(spec=["app_id", "app_name", "installations"]) + provider.identity.app_id = payload + provider.identity.app_name = payload + provider.identity.installations = [payload] + provider.auth_method = payload + + summary = output.get_assessment_summary(provider) + + assert payload not in summary + assert "<script>alert(1)</script>" in summary + + def test_iac_get_assessment_summary_escapes_scan_path(self): + """The IAC scan_path branch (else of `if scan_repository_url`) must escape it.""" + payload = "" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "iac" + provider.scan_repository_url = None + provider.scan_path = payload + provider.auth_method = payload + + summary = output.get_assessment_summary(provider) + + assert payload not in summary + assert "<script>alert(1)</script>" in summary + + def test_image_get_assessment_summary_escapes_images_list(self): + """The Image `else` branch (no registry, images list) must escape each image.""" + payload = "" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "image" + provider.registry = None + provider.images = [payload] + provider.auth_method = payload + + summary = output.get_assessment_summary(provider) + + assert payload not in summary + assert "<script>alert(1)</script>" in summary + def test_process_markdown_bold_text(self): """Test that **text** is converted to text""" test_text = "This is **bold text** and this is **also bold**" diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions_test.py new file mode 100644 index 0000000000..09cdb3ae8d --- /dev/null +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions_test.py @@ -0,0 +1,762 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import Profile +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +PROFILE_NAME = "workload-profile" +PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}" +ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-role" +READONLY_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role" +UNKNOWN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/cross-account-role" +CUSTOM_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/custom-admin-role" +INLINE_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/inline-admin-role" +NAME_COLLISION_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/name-collision-role" +UNRESOLVED_POLICY_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/unresolved-policy-role" +) +ADMIN_UNRESOLVED_POLICY_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-unresolved-policy-role" +) +INVALID_POLICY_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/invalid-policy-role" +DENY_OVERRIDE_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/deny-override-role" +CONDITIONAL_DENY_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/conditional-deny-role" +) +CONDITIONAL_ADMIN_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/conditional-admin-role" +) +BOUNDED_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/bounded-admin-role" +UNRESOLVED_BOUNDARY_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/unresolved-boundary-role" +) +ADMIN_BOUNDED_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-bounded-admin-role" +) +AWS_ADMIN_POLICY_ARN = "arn:aws:iam::aws:policy/AdministratorAccess" +CUSTOMER_ADMIN_NAMED_POLICY_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AdministratorAccess" +) +CUSTOM_ADMIN_POLICY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/custom-admin" +MANAGED_POLICY_ARN = "arn:aws:iam::aws:policy/ReadOnlyAccess" +CUSTOMER_FULL_ACCESS_POLICY_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/full-access-session" +) +BOUNDARY_POLICY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/scoped-boundary" +UNRESOLVED_BOUNDARY_POLICY_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved-boundary" +) +UNRESOLVED_SESSION_POLICY_ARN = ( + "arn:aws:iam::aws:policy/job-function/SupportUser" # not in iam_client.policies +) + +SESSION_POLICY = ( + '{"Version":"2012-10-17","Statement":[{"Effect":"Allow",' + '"Action":["s3:GetObject"],"Resource":["*"]}]}' +) +FULL_ACCESS_SESSION_POLICY = ( + '{"Version":"2012-10-17","Statement":[{"Effect":"Allow",' + '"Action":"*","Resource":"*"}]}' +) + +FULL_ACCESS_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} +READONLY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "s3:Get*", "Resource": "*"}], +} +DENY_ALL_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Deny", "Action": "*", "Resource": "*"}], +} +CONDITIONAL_ADMIN_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "*", + "Resource": "*", + "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}}, + } + ], +} +CONDITIONAL_DENY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Action": "*", + "Resource": "*", + "Condition": {"StringNotEquals": {"aws:PrincipalTag/team": "security"}}, + } + ], +} + + +def _profile( + *, + enabled: bool = True, + session_policy: str = "", + managed_policy_arns=None, + role_arns=None, +): + return Profile( + arn=PROFILE_ARN, + id=PROFILE_ID, + name=PROFILE_NAME, + region=AWS_REGION_US_EAST_1, + enabled=enabled, + role_arns=role_arns if role_arns is not None else [READONLY_ROLE_ARN], + session_policy=session_policy, + managed_policy_arns=managed_policy_arns or [], + ) + + +def _role(arn, attached_policies=None, inline_policies=None, permissions_boundary=None): + return SimpleNamespace( + arn=arn, + attached_policies=attached_policies or [], + inline_policies=inline_policies or [], + permissions_boundary=permissions_boundary, + ) + + +def _iam_client(): + """IAM client stub mirroring iam_service models: roles with attached/inline + policies and a policies dict keyed by ARN (inline keyed {role_arn}:policy/{name}). + """ + roles = [ + _role( + ADMIN_ROLE_ARN, + attached_policies=[ + {"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN} + ], + ), + _role( + READONLY_ROLE_ARN, + attached_policies=[ + {"PolicyName": "ReadOnlyAccess", "PolicyArn": MANAGED_POLICY_ARN} + ], + ), + _role( + CUSTOM_ADMIN_ROLE_ARN, + attached_policies=[ + {"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN} + ], + ), + _role(INLINE_ADMIN_ROLE_ARN, inline_policies=["inline-admin"]), + _role( + NAME_COLLISION_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "AdministratorAccess", + "PolicyArn": CUSTOMER_ADMIN_NAMED_POLICY_ARN, + } + ], + ), + _role( + UNRESOLVED_POLICY_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "unresolved", + "PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved", + } + ], + ), + # Proven admin attached policy combined with an unresolved one: the + # unresolved document could contain a deny, so the outcome is unknown. + _role( + ADMIN_UNRESOLVED_POLICY_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "AdministratorAccess", + "PolicyArn": AWS_ADMIN_POLICY_ARN, + }, + { + "PolicyName": "unresolved", + "PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved", + }, + ], + ), + # Attached policy resolves to a malformed (non-dict) document. + _role( + INVALID_POLICY_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "invalid", + "PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/invalid", + } + ], + ), + # Allow *:* in the attached policy negated by an unconditional Deny *:* + # in an inline policy: not effectively administrative. + _role( + DENY_OVERRIDE_ROLE_ARN, + attached_policies=[ + {"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN} + ], + inline_policies=["deny-all"], + ), + # Allow *:* in the attached policy plus a Condition-guarded Deny: the + # deny may or may not apply, so the outcome is unknown. + _role( + CONDITIONAL_DENY_ROLE_ARN, + attached_policies=[ + {"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN} + ], + inline_policies=["conditional-deny"], + ), + # Allow *:* guarded by a Condition: not statically provable as admin. + _role( + CONDITIONAL_ADMIN_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "conditional-admin", + "PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/conditional-admin", + } + ], + ), + # Administrative identity policies constrained by a restrictive + # permissions boundary: not effectively administrative. + _role( + BOUNDED_ADMIN_ROLE_ARN, + attached_policies=[ + {"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN} + ], + permissions_boundary={ + "PermissionsBoundaryType": "Policy", + "PermissionsBoundaryArn": BOUNDARY_POLICY_ARN, + }, + ), + # Boundary present but its document is not in the IAM inventory: + # restrictions cannot be evaluated, so the role is not classified admin. + _role( + UNRESOLVED_BOUNDARY_ROLE_ARN, + attached_policies=[ + {"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN} + ], + permissions_boundary={ + "PermissionsBoundaryType": "Policy", + "PermissionsBoundaryArn": UNRESOLVED_BOUNDARY_POLICY_ARN, + }, + ), + # AdministratorAccess as the boundary does not restrict anything. + _role( + ADMIN_BOUNDED_ROLE_ARN, + attached_policies=[ + {"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN} + ], + permissions_boundary={ + "PermissionsBoundaryType": "Policy", + "PermissionsBoundaryArn": AWS_ADMIN_POLICY_ARN, + }, + ), + ] + policies = { + AWS_ADMIN_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT), + CUSTOM_ADMIN_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT), + MANAGED_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT), + # Customer-managed policy that merely shares the AdministratorAccess name. + CUSTOMER_ADMIN_NAMED_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT), + f"{INLINE_ADMIN_ROLE_ARN}:policy/inline-admin": SimpleNamespace( + document=FULL_ACCESS_DOCUMENT + ), + f"{DENY_OVERRIDE_ROLE_ARN}:policy/deny-all": SimpleNamespace( + document=DENY_ALL_DOCUMENT + ), + f"{CONDITIONAL_DENY_ROLE_ARN}:policy/conditional-deny": SimpleNamespace( + document=CONDITIONAL_DENY_DOCUMENT + ), + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/invalid": SimpleNamespace( + document="invalid" + ), + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/conditional-admin": SimpleNamespace( + document=CONDITIONAL_ADMIN_DOCUMENT + ), + BOUNDARY_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT), + # Customer-managed session policy whose document grants *:*. + CUSTOMER_FULL_ACCESS_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT), + } + iam = mock.MagicMock() + iam.roles = roles + iam.policies = policies + return iam + + +def _build_client(profiles): + ra_client = mock.MagicMock() + ra_client.profiles = profiles + return ra_client + + +def _patched(ra_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + check_module = "prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions" + return [ + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{check_module}.rolesanywhere_client", new=ra_client), + mock.patch(f"{check_module}.iam_client", new=_iam_client()), + ] + + +def _enter(patches): + from contextlib import ExitStack + + stack = ExitStack() + for p in patches: + stack.enter_context(p) + return stack + + +def _run(): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions import ( + rolesanywhere_profile_restricts_session_permissions, + ) + + return rolesanywhere_profile_restricts_session_permissions().execute() + + +class Test_rolesanywhere_profile_restricts_session_permissions: + def test_no_profiles(self): + with _enter(_patched(_build_client({}))): + assert len(_run()) == 0 + + def test_unscoped_profile_with_admin_role_fails(self): + with _enter( + _patched(_build_client({PROFILE_ARN: _profile(role_arns=[ADMIN_ROLE_ARN])})) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == PROFILE_ID + assert result[0].resource_arn == PROFILE_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert ADMIN_ROLE_ARN in result[0].status_extended + assert "administrative" in result[0].status_extended + + def test_unscoped_profile_with_custom_admin_policy_fails(self): + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[CUSTOM_ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert CUSTOM_ADMIN_ROLE_ARN in result[0].status_extended + + def test_unscoped_profile_with_inline_admin_policy_fails(self): + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[INLINE_ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert INLINE_ADMIN_ROLE_ARN in result[0].status_extended + + def test_mixed_roles_fail_lists_only_admin_role(self): + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + role_arns=[READONLY_ROLE_ARN, ADMIN_ROLE_ARN] + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ADMIN_ROLE_ARN in result[0].status_extended + assert READONLY_ROLE_ARN not in result[0].status_extended + + def test_customer_policy_named_administratoraccess_passes(self): + # Name collision: customer-managed policy called AdministratorAccess + # whose document is read-only must not flag the role as administrative. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[NAME_COLLISION_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_unresolved_attached_policy_is_manual(self): + # Attached policy ARN missing from iam_client.policies: a missing + # document is unknown, not proof that the role is unprivileged. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[UNRESOLVED_POLICY_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be evaluated" in result[0].status_extended + + def test_invalid_policy_document_is_manual(self): + # A malformed policy document cannot prove anything about the role. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[INVALID_POLICY_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_allow_all_with_unresolved_policy_is_manual(self): + # Proven admin policy plus an unresolved one: the unresolved document + # could contain a deny, so the classification is unknown. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + role_arns=[ADMIN_UNRESOLVED_POLICY_ROLE_ARN] + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_unscoped_profile_with_least_privilege_role_passes(self): + with _enter( + _patched( + _build_client({PROFILE_ARN: _profile(role_arns=[READONLY_ROLE_ARN])}) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "defense-in-depth" in result[0].status_extended + + def test_unscoped_profile_with_unknown_role_is_manual(self): + # A referenced role missing from the IAM inventory is unknown, not + # proof that no administrative role exists. + with _enter( + _patched( + _build_client({PROFILE_ARN: _profile(role_arns=[UNKNOWN_ROLE_ARN])}) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be evaluated" in result[0].status_extended + + def test_unscoped_profile_without_roles_passes(self): + with _enter(_patched(_build_client({PROFILE_ARN: _profile(role_arns=[])}))): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_profile_with_session_policy_passes(self): + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=SESSION_POLICY, role_arns=[ADMIN_ROLE_ARN] + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "session policy" in result[0].status_extended + + def test_full_access_session_policy_does_not_scope(self): + # A sessionPolicy granting *:* does not restrict anything. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=FULL_ACCESS_SESSION_POLICY, + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_profile_with_managed_policies_passes(self): + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + managed_policy_arns=[MANAGED_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_admin_managed_session_policy_does_not_scope(self): + # AdministratorAccess as the managed session policy restricts nothing. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + managed_policy_arns=[AWS_ADMIN_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_restrictive_inline_with_admin_managed_policy_fails(self): + # The session-policy set is evaluated as a union: AdministratorAccess as + # a managed session policy makes the boundary unrestricted even though + # the inline session policy is restrictive. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=SESSION_POLICY, + managed_policy_arns=[AWS_ADMIN_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_full_access_inline_with_restrictive_managed_policy_fails(self): + # Conversely, a *:* inline session policy leaves the union unrestricted + # regardless of a restrictive managed session policy. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=FULL_ACCESS_SESSION_POLICY, + managed_policy_arns=[MANAGED_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_restrictive_inline_and_restrictive_managed_policy_passes(self): + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=SESSION_POLICY, + managed_policy_arns=[MANAGED_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_customer_managed_full_access_session_policy_fails(self): + # A customer-managed session policy whose document grants *:* must be + # resolved through iam_client.policies and treated as unscoped. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + managed_policy_arns=[CUSTOMER_FULL_ACCESS_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_unresolved_managed_session_policy_is_manual(self): + # A managed session policy whose document was not collected does not + # prove that the session is restricted: the outcome is unknown. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + managed_policy_arns=[UNRESOLVED_SESSION_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "session scoping could not be evaluated" in result[0].status_extended + + def test_invalid_inline_session_policy_is_manual(self): + # An inline session policy that fails to parse does not prove that the + # session is restricted: the outcome is unknown. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy="not-json", role_arns=[ADMIN_ROLE_ARN] + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_allow_all_with_cross_policy_deny_all_passes(self): + # Allow *:* in an attached policy plus an unconditional Deny *:* in an + # inline policy: the merged evaluation must not classify the role admin. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[DENY_OVERRIDE_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_conditional_admin_allow_is_manual(self): + # An Allow *:* guarded by a Condition is not statically provable in + # either direction: the classification is unknown. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[CONDITIONAL_ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_allow_all_with_conditional_deny_is_manual(self): + # Unconditional Allow *:* plus a Condition-guarded Deny: the deny may + # or may not negate the grant, so the classification is unknown. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[CONDITIONAL_DENY_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_admin_role_with_restrictive_boundary_passes(self): + # Admin identity policies intersected with a read-only permissions + # boundary are not effectively administrative. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[BOUNDED_ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_admin_role_with_unresolved_boundary_is_manual(self): + # When the boundary document cannot be resolved the restrictions are + # unknown: neither administrative nor safe can be proven. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[UNRESOLVED_BOUNDARY_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_admin_role_with_admin_boundary_fails(self): + # An AdministratorAccess boundary restricts nothing: still admin. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[ADMIN_BOUNDED_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ADMIN_BOUNDED_ROLE_ARN in result[0].status_extended + + def test_disabled_profile_passes(self): + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(enabled=False, role_arns=[ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "disabled" in result[0].status_extended diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py index d21d4e20b7..5a1106c852 100644 --- a/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py @@ -4,6 +4,7 @@ import botocore from moto import mock_aws from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + Profile, RolesAnywhere, TrustAnchor, ) @@ -16,6 +17,9 @@ from tests.providers.aws.utils import ( TA_ID = "11111111-2222-3333-4444-555555555555" TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}" PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc" +PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}" +ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role" make_api_call = botocore.client.BaseClient._make_api_call @@ -36,6 +40,21 @@ def mock_make_api_call(self, operation_name, kwarg): } ] } + if operation_name == "ListProfiles": + return { + "profiles": [ + { + "profileArn": PROFILE_ARN, + "profileId": PROFILE_ID, + "name": "workload-profile", + "enabled": True, + "roleArns": [ROLE_ARN], + "sessionPolicy": '{"Version":"2012-10-17","Statement":[]}', + "durationSeconds": 3600, + "acceptRoleSessionName": True, + } + ] + } if operation_name == "ListTagsForResource": return {"tags": [{"key": "Environment", "value": "test"}]} return make_api_call(self, operation_name, kwarg) @@ -78,6 +97,25 @@ class Test_RolesAnywhere_Service: assert ta.region == AWS_REGION_US_EAST_1 assert ta.tags == [{"key": "Environment", "value": "test"}] + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_profiles(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert len(rolesanywhere.profiles) == 1 + profile = rolesanywhere.profiles[PROFILE_ARN] + assert isinstance(profile, Profile) + assert profile.id == PROFILE_ID + assert profile.name == "workload-profile" + assert profile.enabled is True + assert profile.role_arns == [ROLE_ARN] + assert profile.session_policy == '{"Version":"2012-10-17","Statement":[]}' + assert profile.managed_policy_arns == [] + assert profile.duration_seconds == 3600 + assert profile.accept_role_session_name is True + assert profile.region == AWS_REGION_US_EAST_1 + assert profile.tags == [{"key": "Environment", "value": "test"}] + @patch( "botocore.client.BaseClient._make_api_call", new=mock_make_api_call_tags_failure ) diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index 1abcaf5bf0..5964d51142 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -490,6 +490,19 @@ def mock_api_projects_calls(client: MagicMock): } client.projects().serviceAccounts().list_next.return_value = None + # Workload Identity Federation pools/providers: return empty pages and stop + # pagination so the discovery while-loops in the IAM service terminate. + client.projects().locations().workloadIdentityPools().list().execute.return_value = { + "workloadIdentityPools": [] + } + client.projects().locations().workloadIdentityPools().list_next.return_value = None + client.projects().locations().workloadIdentityPools().providers().list().execute.return_value = { + "workloadIdentityPoolProviders": [] + } + client.projects().locations().workloadIdentityPools().providers().list_next.return_value = ( + None + ) + def mock_list_service_accounts_keys(name): return_value = MagicMock() if ( diff --git a/tests/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition_test.py b/tests/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition_test.py new file mode 100644 index 0000000000..6fcc609489 --- /dev/null +++ b/tests/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition_test.py @@ -0,0 +1,148 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +CHECK_MODULE = "prowler.providers.gcp.services.iam.iam_workload_identity_pool_provider_attribute_condition.iam_workload_identity_pool_provider_attribute_condition" + + +def _run(provider_kwargs): + iam_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam_client), + ): + from prowler.providers.gcp.services.iam.iam_service import ( + WorkloadIdentityPoolProvider, + ) + from prowler.providers.gcp.services.iam.iam_workload_identity_pool_provider_attribute_condition.iam_workload_identity_pool_provider_attribute_condition import ( + iam_workload_identity_pool_provider_attribute_condition, + ) + + providers = [] + for kwargs in provider_kwargs: + provider_id = kwargs.get("provider_id", "my-provider") + providers.append( + WorkloadIdentityPoolProvider( + name=( + f"projects/{GCP_PROJECT_ID}/locations/global/" + f"workloadIdentityPools/my-pool/providers/{provider_id}" + ), + id=provider_id, + pool_id="my-pool", + pool_disabled=kwargs.get("pool_disabled", False), + project_id=GCP_PROJECT_ID, + state=kwargs.get("state", "ACTIVE"), + disabled=kwargs.get("disabled", False), + attribute_condition=kwargs.get("attribute_condition", ""), + provider_type=kwargs.get("provider_type", "oidc"), + issuer_uri=kwargs.get( + "issuer_uri", + "https://token.actions.githubusercontent.com", + ), + display_name="My Provider", + ) + ) + + iam_client.project_ids = [GCP_PROJECT_ID] + iam_client.region = GCP_US_CENTER1_LOCATION + iam_client.workload_identity_pool_providers = providers + return iam_workload_identity_pool_provider_attribute_condition().execute() + + +class Test_iam_workload_identity_pool_provider_attribute_condition: + def test_no_providers(self): + assert len(_run([])) == 0 + + def test_multi_tenant_issuer_without_attribute_condition_fails(self): + result = _run( + [ + { + "attribute_condition": "", + "issuer_uri": "https://token.actions.githubusercontent.com", + } + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id.endswith("my-provider") + assert result[0].location == "global" + assert "multi-tenant issuer" in result[0].status_extended + + def test_dedicated_issuer_without_attribute_condition_passes(self): + result = _run( + [ + { + "attribute_condition": "", + "issuer_uri": "https://oidc.eks.eu-west-1.amazonaws.com/id/ABC123", + } + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "dedicated issuer" in result[0].status_extended + + def test_non_oidc_provider_without_attribute_condition_passes(self): + result = _run( + [{"attribute_condition": "", "provider_type": "aws", "issuer_uri": ""}] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "not an OIDC provider" in result[0].status_extended + + def test_multi_tenant_issuer_with_port_and_uppercase_fails(self): + result = _run( + [{"attribute_condition": "", "issuer_uri": "https://GitLab.com:443"}] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multi_tenant_issuer_bare_host_fails(self): + result = _run( + [ + { + "attribute_condition": "", + "issuer_uri": "token.actions.githubusercontent.com", + } + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multi_tenant_issuer_with_attribute_condition_passes(self): + result = _run( + [ + { + "attribute_condition": "assertion.repository_owner == 'acme'", + "issuer_uri": "https://token.actions.githubusercontent.com", + } + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "enforces an attribute condition" in result[0].status_extended + + def test_disabled_provider_passes(self): + result = _run([{"disabled": True}]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "not active" in result[0].status_extended + + def test_non_active_provider_passes(self): + result = _run([{"state": "DELETED"}]) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_active_provider_in_disabled_pool_passes(self): + # The provider itself is ACTIVE and unconditioned on a multi-tenant + # issuer, but its parent pool is disabled and cannot vend credentials. + result = _run([{"pool_disabled": True}]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "disabled pool" in result[0].status_extended diff --git a/tests/providers/gcp/services/iam/iam_workloadidentity_service_test.py b/tests/providers/gcp/services/iam/iam_workloadidentity_service_test.py new file mode 100644 index 0000000000..51385b0204 --- /dev/null +++ b/tests/providers/gcp/services/iam/iam_workloadidentity_service_test.py @@ -0,0 +1,215 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + +PROJECT_A = GCP_PROJECT_ID +PROJECT_B = "test-project-b" + + +def _pool_name(project_id, pool_id="my-pool"): + return f"projects/{project_id}/locations/global/workloadIdentityPools/{pool_id}" + + +def _provider_payload(pool_name, provider_id="my-provider"): + return { + "name": f"{pool_name}/providers/{provider_id}", + "state": "ACTIVE", + "disabled": False, + "attributeMapping": {"google.subject": "assertion.sub"}, + "oidc": {"issuerUri": "https://token.actions.githubusercontent.com"}, + "displayName": "gh", + } + + +def _empty_service_accounts(client): + """Stub the service-account calls used by the rest of the IAM __init__.""" + sa = client.projects.return_value.serviceAccounts.return_value + sa.list.return_value.execute.return_value = {"accounts": []} + sa.list_next.return_value = None + + +def _wif_client(_GCPService, _service, _api_version, _credentials): + """Discovery client stub returning one pool with one provider.""" + client = MagicMock() + + pool_name = _pool_name(GCP_PROJECT_ID) + pools = ( + client.projects.return_value.locations.return_value.workloadIdentityPools.return_value + ) + pools.list.return_value.execute.return_value = { + "workloadIdentityPools": [{"name": pool_name, "state": "ACTIVE"}] + } + pools.list_next.return_value = None + + providers = pools.providers.return_value + providers.list.return_value.execute.return_value = { + "workloadIdentityPoolProviders": [_provider_payload(pool_name)] + } + providers.list_next.return_value = None + + _empty_service_accounts(client) + return client + + +def _disabled_pool_client(_GCPService, _service, _api_version, _credentials): + """Discovery client stub: a disabled pool containing an ACTIVE provider.""" + client = MagicMock() + + pool_name = _pool_name(GCP_PROJECT_ID) + pools = ( + client.projects.return_value.locations.return_value.workloadIdentityPools.return_value + ) + pools.list.return_value.execute.return_value = { + "workloadIdentityPools": [ + {"name": pool_name, "state": "ACTIVE", "disabled": True} + ] + } + pools.list_next.return_value = None + + providers = pools.providers.return_value + providers.list.return_value.execute.return_value = { + "workloadIdentityPoolProviders": [_provider_payload(pool_name)] + } + providers.list_next.return_value = None + + _empty_service_accounts(client) + return client + + +def _pool_list_failure_client(_GCPService, _service, _api_version, _credentials): + """Pool listing fails for PROJECT_A but succeeds for PROJECT_B.""" + client = MagicMock() + pool_name_b = _pool_name(PROJECT_B) + + pools = ( + client.projects.return_value.locations.return_value.workloadIdentityPools.return_value + ) + + def pools_list(parent): + request = MagicMock() + if f"projects/{PROJECT_A}/" in parent: + request.execute.side_effect = Exception("permission denied listing pools") + else: + request.execute.return_value = { + "workloadIdentityPools": [{"name": pool_name_b, "state": "ACTIVE"}] + } + return request + + pools.list.side_effect = pools_list + pools.list_next.return_value = None + + providers = pools.providers.return_value + providers.list.return_value.execute.return_value = { + "workloadIdentityPoolProviders": [_provider_payload(pool_name_b)] + } + providers.list_next.return_value = None + + _empty_service_accounts(client) + return client + + +def _provider_list_failure_client(_GCPService, _service, _api_version, _credentials): + """Provider listing fails for pool-1 but succeeds for pool-2 in one project.""" + client = MagicMock() + pool_1 = _pool_name(GCP_PROJECT_ID, "pool-1") + pool_2 = _pool_name(GCP_PROJECT_ID, "pool-2") + + pools = ( + client.projects.return_value.locations.return_value.workloadIdentityPools.return_value + ) + pools.list.return_value.execute.return_value = { + "workloadIdentityPools": [ + {"name": pool_1, "state": "ACTIVE"}, + {"name": pool_2, "state": "ACTIVE"}, + ] + } + pools.list_next.return_value = None + + providers = pools.providers.return_value + + def providers_list(parent): + request = MagicMock() + if parent == pool_1: + request.execute.side_effect = Exception( + "permission denied listing providers" + ) + else: + request.execute.return_value = { + "workloadIdentityPoolProviders": [ + _provider_payload(pool_2, provider_id="provider-2") + ] + } + return request + + providers.list.side_effect = providers_list + providers.list_next.return_value = None + + _empty_service_accounts(client) + return client + + +def _run_service(client_factory, project_ids): + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(project_ids=project_ids), + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=client_factory, + ), + ): + from prowler.providers.gcp.services.iam.iam_service import IAM + + return IAM(set_mocked_gcp_provider(project_ids=project_ids)) + + +class TestIAMWorkloadIdentityService: + def test_get_workload_identity_pool_providers(self): + iam = _run_service(_wif_client, [GCP_PROJECT_ID]) + + assert len(iam.workload_identity_pool_providers) == 1 + provider = iam.workload_identity_pool_providers[0] + assert provider.id == "my-provider" + assert provider.pool_id == "my-pool" + assert provider.project_id == GCP_PROJECT_ID + assert provider.state == "ACTIVE" + assert provider.disabled is False + assert provider.pool_disabled is False + assert provider.attribute_condition == "" + assert provider.provider_type == "oidc" + assert provider.issuer_uri == "https://token.actions.githubusercontent.com" + + def test_disabled_pool_state_propagates_to_provider(self): + iam = _run_service(_disabled_pool_client, [GCP_PROJECT_ID]) + + # The provider is ACTIVE, but its parent pool is disabled: the pool's + # effective state must travel with the provider record. + assert len(iam.workload_identity_pool_providers) == 1 + provider = iam.workload_identity_pool_providers[0] + assert provider.state == "ACTIVE" + assert provider.disabled is False + assert provider.pool_disabled is True + + def test_pool_list_failure_does_not_block_other_projects(self): + iam = _run_service(_pool_list_failure_client, [PROJECT_A, PROJECT_B]) + + # PROJECT_A's pool listing failed, but PROJECT_B is still processed. + assert len(iam.workload_identity_pool_providers) == 1 + assert iam.workload_identity_pool_providers[0].project_id == PROJECT_B + + def test_provider_list_failure_only_skips_that_pool(self): + iam = _run_service(_provider_list_failure_client, [GCP_PROJECT_ID]) + + # pool-1's provider listing failed, but pool-2's provider is still found. + assert len(iam.workload_identity_pool_providers) == 1 + assert iam.workload_identity_pool_providers[0].pool_id == "pool-2" + assert iam.workload_identity_pool_providers[0].id == "provider-2" diff --git a/tests/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited_test.py b/tests/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited_test.py index 2bacd5dda1..652e579338 100644 --- a/tests/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited_test.py +++ b/tests/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited_test.py @@ -1,5 +1,6 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.github.services.organization.organization_service import Org from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -58,6 +59,7 @@ class Test_organization_repository_creation_limited: assert len(result) == 1 assert result[0].resource_name == org_name assert result[0].status == "PASS" + assert result[0].check_metadata.Severity == Severity.high assert ( result[0].status_extended == f"Organization {org_name} has disabled repository creation for members." @@ -94,6 +96,7 @@ class Test_organization_repository_creation_limited: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high assert "public repositories" in result[0].status_extended assert "repositories of any type" in result[0].status_extended @@ -129,6 +132,7 @@ class Test_organization_repository_creation_limited: result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" + assert result[0].check_metadata.Severity == Severity.high assert ( result[0].status_extended == f"Organization {org_name} has disabled repository creation for members." @@ -164,7 +168,410 @@ class Test_organization_repository_creation_limited: result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" + assert result[0].check_metadata.Severity == Severity.high assert ( result[0].status_extended == f"Organization {org_name} has disabled repository creation for members." ) + + def test_repository_creation_type_none_with_private_enabled(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_create_repositories=True, + members_allowed_repository_creation_type="none", + members_can_create_private_repositories=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.low + assert "repositories of any type" not in result[0].status_extended + assert "private repositories" in result[0].status_extended + assert ( + "Public repository creation is disabled." in result[0].status_extended + ) + + def test_repository_creation_public_flag_only(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_create_public_repositories=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + + def test_repository_creation_private_only(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_create_repositories=True, + members_can_create_public_repositories=False, + members_can_create_private_repositories=True, + members_can_create_internal_repositories=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.low + assert ( + "Public repository creation is disabled." in result[0].status_extended + ) + assert "repositories of any type" not in result[0].status_extended + + def test_repository_creation_public_flag_unknown_is_not_downgraded(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_create_private_repositories=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert ( + "Public repository creation is disabled." + not in result[0].status_extended + ) + + def test_repository_creation_internal_only(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_create_public_repositories=False, + members_can_create_private_repositories=False, + members_can_create_internal_repositories=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.low + assert ( + "Public repository creation is disabled." in result[0].status_extended + ) + + def test_repository_creation_private_and_internal(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_create_public_repositories=False, + members_can_create_private_repositories=True, + members_can_create_internal_repositories=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.low + assert ( + "Public repository creation is disabled." in result[0].status_extended + ) + + def test_repository_creation_type_all(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_allowed_repository_creation_type="all", + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + + def test_repository_creation_type_private(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_allowed_repository_creation_type="private", + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.low + assert ( + "Public repository creation is disabled." in result[0].status_extended + ) + + def test_repository_creation_type_selected_is_undeterminable(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_allowed_repository_creation_type="selected", + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + + def test_repository_creation_global_flag_only(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_create_repositories=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + + def test_repository_creation_severity_does_not_leak_between_organizations(self): + organization_client = mock.MagicMock + public_org_name = "public-organization" + private_org_name = "private-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=public_org_name, + mfa_required=None, + members_can_create_public_repositories=True, + ), + 2: Org( + id=2, + name=private_org_name, + mfa_required=None, + members_can_create_public_repositories=False, + members_can_create_private_repositories=True, + members_can_create_internal_repositories=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_creation_limited.organization_repository_creation_limited import ( + organization_repository_creation_limited, + ) + + check = organization_repository_creation_limited() + result = check.execute() + assert len(result) == 2 + + results_by_name = {r.resource_name: r for r in result} + assert results_by_name[public_org_name].status == "FAIL" + assert ( + results_by_name[public_org_name].check_metadata.Severity + == Severity.high + ) + assert results_by_name[private_org_name].status == "FAIL" + assert ( + results_by_name[private_org_name].check_metadata.Severity + == Severity.low + ) diff --git a/tests/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured_test.py b/tests/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured_test.py new file mode 100644 index 0000000000..5ee806e248 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured_test.py @@ -0,0 +1,147 @@ +from unittest import mock + +import pytest + +from prowler.providers.m365.services.entra.entra_service import ( + AccessReviewDefinition, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_access_review_guest_users_configured.entra_access_review_guest_users_configured" + + +def _definition( + status="InProgress", + scope_query="/users?$filter=(userType eq 'Guest')", + principal_scope_queries=None, + default_decision="Deny", + default_decision_enabled=True, + auto_apply_enabled=True, + mail_notifications_enabled=True, + reminders_enabled=True, + recurrence_pattern_type="weekly", + recurrence_range_type="noEnd", + has_primary_reviewers=True, +): + definition = { + "id": "ar1", + "display_name": "Guest Review", + "status": status, + "scope_query": scope_query, + "principal_scope_queries": principal_scope_queries or [], + "default_decision": default_decision, + "auto_apply_enabled": auto_apply_enabled, + "mail_notifications_enabled": mail_notifications_enabled, + "reminders_enabled": reminders_enabled, + "recurrence_pattern_type": recurrence_pattern_type, + "recurrence_range_type": recurrence_range_type, + "has_primary_reviewers": has_primary_reviewers, + } + if default_decision_enabled is not None: + definition["default_decision_enabled"] = default_decision_enabled + return AccessReviewDefinition(**definition) + + +class Test_entra_access_review_guest_users_configured: + def _run(self, definitions): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_access_review_guest_users_configured.entra_access_review_guest_users_configured import ( + entra_access_review_guest_users_configured, + ) + + entra_client.access_review_definitions = definitions + return entra_access_review_guest_users_configured().execute() + + def test_no_definitions(self): + result = self._run([]) + assert result[0].status == "FAIL" + + def test_active_failclosed_guest_review(self): + result = self._run([_definition()]) + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Access review 'Guest Review' for guest users is active, recurring, " + "reviewer-assigned, and fail-closed." + ) + + def test_not_active(self): + result = self._run([_definition(status="Completed")]) + assert result[0].status == "FAIL" + + def test_not_fail_closed(self): + # Active guest review but does nothing on non-response -> FAIL. + result = self._run( + [_definition(default_decision="None", auto_apply_enabled=False)] + ) + assert result[0].status == "FAIL" + + @pytest.mark.parametrize("default_decision_enabled", [False, None]) + def test_default_decision_not_enabled(self, default_decision_enabled): + result = self._run( + [_definition(default_decision_enabled=default_decision_enabled)] + ) + assert result[0].status == "FAIL" + + def test_not_guest_scope(self): + result = self._run([_definition(scope_query="/roleManagement/directory")]) + assert result[0].status == "FAIL" + + def test_guest_filter_in_principal_scopes(self): + # Portal-created reviews keep the guest filter in principalScopes and + # leave the top-level scope query empty. + result = self._run( + [ + _definition( + scope_query="", + principal_scope_queries=["/users?$filter=(userType eq 'Guest')"], + ) + ] + ) + assert result[0].status == "PASS" + + def test_url_encoded_guest_filter(self): + result = self._run( + [_definition(scope_query="/users?$filter=userType%20eq%20%27Guest%27")] + ) + assert result[0].status == "PASS" + + def test_mixed_case_guest_filter(self): + result = self._run( + [_definition(scope_query="/users?$filter=(USERTYPE EQ 'guest')")] + ) + assert result[0].status == "PASS" + + @pytest.mark.parametrize( + "scope_query", + [ + "/users?$filter=not(userType eq 'Guest')", + "/users?$filter=(userType ne 'Guest')", + "/users?$filter=displayName eq 'Guest account'", + "/groups/guest-review-members", + ], + ) + def test_incidental_guest_text_does_not_target_guests(self, scope_query): + result = self._run([_definition(scope_query=scope_query)]) + assert result[0].status == "FAIL" + + @pytest.mark.parametrize( + "definition_overrides", + [ + {"recurrence_pattern_type": None}, + {"recurrence_pattern_type": "daily"}, + {"recurrence_range_type": None}, + {"recurrence_range_type": "endDate"}, + {"has_primary_reviewers": False}, + ], + ) + def test_invalid_recurrence_or_missing_reviewers(self, definition_overrides): + result = self._run([_definition(**definition_overrides)]) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured_test.py b/tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured_test.py new file mode 100644 index 0000000000..a982dc8b8a --- /dev/null +++ b/tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured_test.py @@ -0,0 +1,97 @@ +from unittest import mock + +import pytest + +from prowler.providers.m365.services.entra.entra_service import ( + AccessReviewDefinition, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_access_review_privileged_roles_configured.entra_access_review_privileged_roles_configured" + + +def _definition( + status="InProgress", + scope_query="", + resource_scope_queries=None, + default_decision="None", + auto_apply_enabled=True, + mail_notifications_enabled=True, + reminders_enabled=True, + recurrence_pattern_type="absoluteMonthly", + recurrence_range_type="noEnd", + has_primary_reviewers=True, +): + return AccessReviewDefinition( + id="ar1", + display_name="Privileged Roles Review", + status=status, + scope_query=scope_query, + resource_scope_queries=( + resource_scope_queries + if resource_scope_queries is not None + else ["/roleManagement/directory/roleDefinitions/62e90394-..."] + ), + default_decision=default_decision, + auto_apply_enabled=auto_apply_enabled, + mail_notifications_enabled=mail_notifications_enabled, + reminders_enabled=reminders_enabled, + recurrence_pattern_type=recurrence_pattern_type, + recurrence_range_type=recurrence_range_type, + has_primary_reviewers=has_primary_reviewers, + ) + + +class Test_entra_access_review_privileged_roles_configured: + def _run(self, definitions): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_access_review_privileged_roles_configured.entra_access_review_privileged_roles_configured import ( + entra_access_review_privileged_roles_configured, + ) + + entra_client.access_review_definitions = definitions + return entra_access_review_privileged_roles_configured().execute() + + def test_no_definitions(self): + assert self._run([])[0].status == "FAIL" + + def test_active_fail_closed_privileged(self): + # Role reference lives in resource scopes (not top-level scope.query). + result = self._run([_definition()]) + assert result[0].status == "PASS" + + def test_deny_default_decision(self): + result = self._run([_definition(default_decision="Deny")]) + assert result[0].status == "FAIL" + + def test_guest_review_ignored(self): + result = self._run( + [ + _definition( + scope_query="/users?$filter=(userType eq 'Guest')", + resource_scope_queries=[], + ) + ] + ) + assert result[0].status == "FAIL" + + @pytest.mark.parametrize( + "definition_overrides", + [ + {"recurrence_pattern_type": None}, + {"recurrence_pattern_type": "daily"}, + {"recurrence_range_type": None}, + {"recurrence_range_type": "numbered"}, + {"has_primary_reviewers": False}, + ], + ) + def test_invalid_recurrence_or_missing_reviewers(self, definition_overrides): + result = self._run([_definition(**definition_overrides)]) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context_test.py b/tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context_test.py new file mode 100644 index 0000000000..d1c7683838 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context_test.py @@ -0,0 +1,59 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthenticationMethodsPolicySettings, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_authentication_method_authenticator_show_context.entra_authentication_method_authenticator_show_context" + + +class Test_entra_authentication_method_authenticator_show_context: + def _run(self, settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_authenticator_show_context.entra_authentication_method_authenticator_show_context import ( + entra_authentication_method_authenticator_show_context, + ) + + entra_client.authentication_methods_policy_settings = settings + return entra_authentication_method_authenticator_show_context().execute() + + def test_no_settings(self): + assert self._run(None) == [] + + def test_both_enabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + authenticator_state="enabled", + authenticator_display_app_information_state="enabled", + authenticator_display_location_information_state="enabled", + ) + ) + assert result[0].status == "PASS" + + def test_location_disabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + authenticator_state="enabled", + authenticator_display_app_information_state="enabled", + authenticator_display_location_information_state="disabled", + ) + ) + assert result[0].status == "FAIL" + + def test_context_enabled_but_authenticator_disabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + authenticator_state="disabled", + authenticator_display_app_information_state="enabled", + authenticator_display_location_information_state="enabled", + ) + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled_test.py b/tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled_test.py new file mode 100644 index 0000000000..7b16ced8ef --- /dev/null +++ b/tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled_test.py @@ -0,0 +1,139 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthenticationMethodConfiguration, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_authentication_method_email_otp_disabled: + def test_no_configurations(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import ( + entra_authentication_method_email_otp_disabled, + ) + + entra_client.authentication_method_configurations = {} + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_email_otp_disabled() + result = check.execute() + + assert len(result) == 0 + + def test_email_otp_enabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import ( + entra_authentication_method_email_otp_disabled, + ) + + entra_client.authentication_method_configurations = { + "Email": AuthenticationMethodConfiguration( + id="Email", + state="enabled", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_email_otp_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Email OTP authentication method is enabled in the tenant." + ) + + def test_email_otp_disabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import ( + entra_authentication_method_email_otp_disabled, + ) + + entra_client.authentication_method_configurations = { + "Email": AuthenticationMethodConfiguration( + id="Email", + state="disabled", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_email_otp_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Email OTP authentication method is disabled in the tenant." + ) + + def test_email_otp_unknown_state(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import ( + entra_authentication_method_email_otp_disabled, + ) + + entra_client.authentication_method_configurations = { + "Email": AuthenticationMethodConfiguration( + id="Email", + state="unknown", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_email_otp_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Email OTP authentication method state could not be determined; " + "treating as enabled/non-compliant." + ) diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked_test.py new file mode 100644 index 0000000000..0dab5d2b62 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked_test.py @@ -0,0 +1,149 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + AuthenticationFlows, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + TransferMethod, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_authentication_transfer_blocked.entra_conditional_access_policy_authentication_transfer_blocked" + + +def _make_policy( + policy_id="policy-1", + display_name="Block Authentication Transfer", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + excluded_applications=None, + transfer_methods=None, + built_in_controls=None, +): + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=excluded_applications or [], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + authentication_flows=AuthenticationFlows( + transfer_methods=( + transfer_methods + if transfer_methods is not None + else [TransferMethod.AUTHENTICATION_TRANSFER] + ) + ), + ), + grant_controls=GrantControls( + built_in_controls=( + built_in_controls + if built_in_controls is not None + else [ConditionalAccessGrantControl.BLOCK] + ), + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_authentication_transfer_blocked: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_authentication_transfer_blocked.entra_conditional_access_policy_authentication_transfer_blocked import ( + entra_conditional_access_policy_authentication_transfer_blocked, + ) + + entra_client.conditional_access_policies = policies + entra_client.tenant_domain = DOMAIN + return ( + entra_conditional_access_policy_authentication_transfer_blocked().execute() + ) + + def test_no_resources(self): + result = self._run({}) + assert len(result) == 0 + + def test_policy_blocks_authentication_transfer(self): + policy = _make_policy() + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy 'Block Authentication Transfer' blocks authentication transfer." + ) + + def test_multiple_policies_each_produce_a_report(self): + blocking_policy = _make_policy(policy_id="policy-blocking") + non_blocking_policy = _make_policy( + policy_id="policy-non-blocking", + display_name="Allow Authentication Transfer", + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + + result = self._run( + { + blocking_policy.id: blocking_policy, + non_blocking_policy.id: non_blocking_policy, + } + ) + + assert len(result) == 2 + assert [report.resource_id for report in result] == [ + "policy-blocking", + "policy-non-blocking", + ] + assert [report.status for report in result] == ["PASS", "FAIL"] + + def test_policy_excluding_application_does_not_cover_all_applications(self): + policy = _make_policy(excluded_applications=["excluded-app"]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_policy_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_does_not_block(self): + policy = _make_policy(built_in_controls=[ConditionalAccessGrantControl.MFA]) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk_test.py new file mode 100644 index 0000000000..076e35d787 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk_test.py @@ -0,0 +1,119 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + RiskLevel, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_high_medium_sign_in_risk.entra_conditional_access_policy_block_high_medium_sign_in_risk" + + +def _make_policy( + policy_id="policy-1", + display_name="Block Risky Sign-ins", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + sign_in_risk_levels=None, + built_in_controls=None, +): + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + sign_in_risk_levels=( + sign_in_risk_levels + if sign_in_risk_levels is not None + else [RiskLevel.HIGH, RiskLevel.MEDIUM] + ), + ), + grant_controls=GrantControls( + built_in_controls=( + built_in_controls + if built_in_controls is not None + else [ConditionalAccessGrantControl.BLOCK] + ), + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_block_high_medium_sign_in_risk: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_high_medium_sign_in_risk.entra_conditional_access_policy_block_high_medium_sign_in_risk import ( + entra_conditional_access_policy_block_high_medium_sign_in_risk, + ) + + entra_client.conditional_access_policies = policies + entra_client.tenant_domain = DOMAIN + return ( + entra_conditional_access_policy_block_high_medium_sign_in_risk().execute() + ) + + def test_no_policies(self): + result = self._run({}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_blocks_high_medium(self): + policy = _make_policy() + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy 'Block Risky Sign-ins' blocks high and medium sign-in risk." + ) + + def test_policy_only_high(self): + policy = _make_policy(sign_in_risk_levels=[RiskLevel.HIGH]) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users_test.py new file mode 100644 index 0000000000..100e24d87a --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users_test.py @@ -0,0 +1,263 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + AuthenticationFlows, + ClientAppType, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + DeviceConditions, + DeviceFilterMode, + GrantControlOperator, + GrantControls, + InsiderRiskLevel, + LocationsCondition, + PersistentBrowser, + PlatformConditions, + RiskLevel, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + SignInFrequencyType, + TransferMethod, + UserAction, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_sign_in_frequency_all_users.entra_conditional_access_policy_sign_in_frequency_all_users" + + +def _make_policy( + policy_id="policy-1", + display_name="Sign-in Frequency", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + excluded_applications=None, + is_enabled=True, + frequency=1, + freq_type=SignInFrequencyType.DAYS, + interval=SignInFrequencyInterval.TIME_BASED, +): + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=excluded_applications or [], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=is_enabled, + frequency=frequency, + type=freq_type, + interval=interval, + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_sign_in_frequency_all_users: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_sign_in_frequency_all_users.entra_conditional_access_policy_sign_in_frequency_all_users import ( + entra_conditional_access_policy_sign_in_frequency_all_users, + ) + + entra_client.conditional_access_policies = policies + entra_client.tenant_domain = DOMAIN + return ( + entra_conditional_access_policy_sign_in_frequency_all_users().execute() + ) + + def test_no_resources(self): + result = self._run({}) + assert len(result) == 0 + + def test_policy_7_days(self): + policy = _make_policy(frequency=7, freq_type=SignInFrequencyType.DAYS) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_policy_every_time(self): + policy = _make_policy(interval=SignInFrequencyInterval.EVERY_TIME) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_policy_excluding_application_does_not_cover_all_applications(self): + policy = _make_policy(excluded_applications=["excluded-app"]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_policy_too_long(self): + policy = _make_policy(frequency=30, freq_type=SignInFrequencyType.DAYS) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_disabled_frequency(self): + policy = _make_policy( + is_enabled=False, frequency=None, freq_type=None, interval=None + ) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "report-only mode" in result[0].status_extended + + def test_policy_risk_scoped_ignored(self): + # A sign-in-frequency policy scoped only to risky sign-ins must not satisfy + # the "all users" control. + policy = _make_policy(frequency=1, freq_type=SignInFrequencyType.DAYS) + policy.conditions.sign_in_risk_levels = [RiskLevel.HIGH] + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_platform_restricted_ignored(self): + policy = _make_policy(frequency=1, freq_type=SignInFrequencyType.DAYS) + policy.conditions.platform_conditions = PlatformConditions( + include_platforms=["windows"], exclude_platforms=[] + ) + + result = self._run({policy.id: policy}) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multiple_policies_each_produce_a_report(self): + compliant_policy = _make_policy(policy_id="policy-compliant") + noncompliant_policy = _make_policy( + policy_id="policy-noncompliant", + display_name="Long Sign-in Frequency", + frequency=30, + ) + + result = self._run( + { + compliant_policy.id: compliant_policy, + noncompliant_policy.id: noncompliant_policy, + } + ) + + assert len(result) == 2 + assert [report.resource_id for report in result] == [ + "policy-compliant", + "policy-noncompliant", + ] + assert [report.status for report in result] == ["PASS", "FAIL"] + + def test_policy_client_app_type_restricted_ignored(self): + policy = _make_policy() + policy.conditions.client_app_types = [ClientAppType.BROWSER] + + result = self._run({policy.id: policy}) + + assert result[0].status == "FAIL" + + def test_policy_location_restricted_ignored(self): + policy = _make_policy() + policy.conditions.locations = LocationsCondition( + include_locations=["location-1"], exclude_locations=[] + ) + + result = self._run({policy.id: policy}) + + assert result[0].status == "FAIL" + + def test_policy_device_restricted_ignored(self): + policy = _make_policy() + policy.conditions.device_conditions = DeviceConditions( + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -eq true", + ) + + result = self._run({policy.id: policy}) + + assert result[0].status == "FAIL" + + def test_policy_authentication_flow_restricted_ignored(self): + policy = _make_policy() + policy.conditions.authentication_flows = AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ) + + result = self._run({policy.id: policy}) + + assert result[0].status == "FAIL" + + def test_policy_insider_risk_restricted_ignored(self): + policy = _make_policy() + policy.conditions.insider_risk_levels = InsiderRiskLevel.ELEVATED + + result = self._run({policy.id: policy}) + + assert result[0].status == "FAIL" + + def test_policy_user_exclusion_restricted_ignored(self): + policy = _make_policy() + policy.conditions.user_conditions.excluded_users = ["excluded-user"] + + result = self._run({policy.id: policy}) + + assert result[0].status == "FAIL" + + def test_policy_user_action_restricted_ignored(self): + policy = _make_policy() + policy.conditions.application_conditions.included_user_actions = [ + UserAction.REGISTER_DEVICE + ] + + result = self._run({policy.id: policy}) + + assert result[0].status == "FAIL" + + def test_explicit_unrestricted_conditions_pass(self): + policy = _make_policy() + policy.conditions.client_app_types = [ClientAppType.ALL] + policy.conditions.platform_conditions = PlatformConditions( + include_platforms=["all"], exclude_platforms=[] + ) + policy.conditions.locations = LocationsCondition( + include_locations=["All"], exclude_locations=[] + ) + policy.conditions.device_conditions = DeviceConditions() + + result = self._run({policy.id: policy}) + + assert result[0].status == "PASS" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced_test.py new file mode 100644 index 0000000000..27abdce673 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced_test.py @@ -0,0 +1,188 @@ +from unittest import mock + +import pytest + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ClientAppType, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + PlatformConditions, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_token_protection_enforced.entra_conditional_access_policy_token_protection_enforced" + + +def _make_policy( + policy_id="policy-1", + display_name="Token Protection", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + excluded_applications=None, + include_platforms=None, + client_app_types=None, + secure_sign_in_session_enabled=True, +): + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=( + included_applications + if included_applications is not None + else ["All"] + ), + excluded_applications=excluded_applications or [], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=( + included_users if included_users is not None else ["All"] + ), + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=( + client_app_types + if client_app_types is not None + else [ClientAppType.MOBILE_APPS_AND_DESKTOP_CLIENTS] + ), + platform_conditions=PlatformConditions( + include_platforms=( + include_platforms if include_platforms is not None else ["windows"] + ), + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + secure_sign_in_session_enabled=secure_sign_in_session_enabled, + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_token_protection_enforced: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_token_protection_enforced.entra_conditional_access_policy_token_protection_enforced import ( + entra_conditional_access_policy_token_protection_enforced, + ) + + entra_client.conditional_access_policies = policies + entra_client.tenant_domain = DOMAIN + return entra_conditional_access_policy_token_protection_enforced().execute() + + def test_no_resources(self): + assert len(self._run({})) == 0 + + def test_token_protection_enforced(self): + policy = _make_policy() + result = self._run({policy.id: policy}) + assert result[0].status == "PASS" + + def test_multiple_policies_each_produce_a_report(self): + enforced_policy = _make_policy(policy_id="policy-enforced") + disabled_policy = _make_policy( + policy_id="policy-disabled", + display_name="Token Protection Disabled", + secure_sign_in_session_enabled=False, + ) + + result = self._run( + { + enforced_policy.id: enforced_policy, + disabled_policy.id: disabled_policy, + } + ) + + assert len(result) == 2 + assert [report.resource_id for report in result] == [ + "policy-enforced", + "policy-disabled", + ] + assert [report.status for report in result] == ["PASS", "FAIL"] + + def test_token_protection_disabled(self): + policy = _make_policy(secure_sign_in_session_enabled=False) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_missing_windows_platform(self): + policy = _make_policy(include_platforms=["macOS"]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_browser_only_client(self): + policy = _make_policy(client_app_types=[ClientAppType.BROWSER]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_partial_app_coverage(self): + policy = _make_policy( + included_applications=["00000002-0000-0ff1-ce00-000000000000"] + ) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_all_required_apps(self): + policy = _make_policy( + included_applications=[ + "00000002-0000-0ff1-ce00-000000000000", + "00000003-0000-0ff1-ce00-000000000000", + "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe", + ] + ) + result = self._run({policy.id: policy}) + assert result[0].status == "PASS" + + @pytest.mark.parametrize( + "excluded_application", + [ + "00000002-0000-0ff1-ce00-000000000000", + "00000003-0000-0ff1-ce00-000000000000", + "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe", + ], + ) + def test_all_apps_excluding_required_app_fails(self, excluded_application): + policy = _make_policy(excluded_applications=[excluded_application]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_all_apps_excluding_unrelated_app_passes(self): + policy = _make_policy(excluded_applications=["unrelated-app"]) + result = self._run({policy.id: policy}) + assert result[0].status == "PASS" + + def test_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked_test.py new file mode 100644 index 0000000000..628a549b2d --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked_test.py @@ -0,0 +1,181 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + LocationsCondition, + NamedLocation, + PersistentBrowser, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_untrusted_locations_blocked.entra_conditional_access_policy_untrusted_locations_blocked" + + +def _make_policy( + state=ConditionalAccessPolicyState.ENABLED, + include_locations=None, + exclude_locations=None, + built_in_controls=None, + excluded_applications=None, +): + return ConditionalAccessPolicy( + id="policy-1", + display_name="Block Untrusted Locations", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=excluded_applications or [], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + locations=LocationsCondition( + include_locations=( + include_locations if include_locations is not None else ["All"] + ), + exclude_locations=( + exclude_locations + if exclude_locations is not None + else ["AllTrusted"] + ), + ), + ), + grant_controls=GrantControls( + built_in_controls=( + built_in_controls + if built_in_controls is not None + else [ConditionalAccessGrantControl.BLOCK] + ), + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_untrusted_locations_blocked: + def _run(self, policies, named_locations=None): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_untrusted_locations_blocked.entra_conditional_access_policy_untrusted_locations_blocked import ( + entra_conditional_access_policy_untrusted_locations_blocked, + ) + + entra_client.conditional_access_policies = policies + entra_client.named_locations = named_locations or [] + entra_client.tenant_domain = DOMAIN + return ( + entra_conditional_access_policy_untrusted_locations_blocked().execute() + ) + + def test_no_policies(self): + assert self._run({})[0].status == "FAIL" + + def test_blocks_untrusted(self): + policy = _make_policy() + result = self._run({policy.id: policy}) + assert result[0].status == "PASS" + + def test_policy_excluding_application_does_not_cover_all_applications(self): + policy = _make_policy(excluded_applications=["excluded-app"]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_blocks_explicit_untrusted_named_location(self): + location_id = "00000000-0000-0000-0000-000000000001" + policy = _make_policy(include_locations=[location_id]) + result = self._run( + {policy.id: policy}, + [NamedLocation(id=location_id, is_trusted=False)], + ) + assert result[0].status == "PASS" + + def test_does_not_accept_explicit_trusted_named_location(self): + location_id = "00000000-0000-0000-0000-000000000001" + policy = _make_policy(include_locations=[location_id]) + result = self._run( + {policy.id: policy}, + [NamedLocation(id=location_id, is_trusted=True)], + ) + assert result[0].status == "FAIL" + + def test_does_not_accept_unknown_named_location(self): + policy = _make_policy( + include_locations=["00000000-0000-0000-0000-000000000001"] + ) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_does_not_accept_explicit_untrusted_location_when_excluded(self): + location_id = "00000000-0000-0000-0000-000000000001" + policy = _make_policy( + include_locations=[location_id], + exclude_locations=["AllTrusted", location_id], + ) + result = self._run( + {policy.id: policy}, + [NamedLocation(id=location_id, is_trusted=False)], + ) + assert result[0].status == "FAIL" + + def test_no_trusted_exclusion(self): + policy = _make_policy(exclude_locations=[]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_policy_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + + result = self._run({policy.id: policy}) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Conditional Access Policy 'Block Untrusted Locations' blocks untrusted " + "locations but is in report-only mode." + ) + + def test_policy_disabled_is_skipped(self): + policy = _make_policy(state=ConditionalAccessPolicyState.DISABLED) + + result = self._run({policy.id: policy}) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].status_extended == ( + "No Conditional Access Policy blocks access from untrusted locations." + ) + + def test_not_block(self): + policy = _make_policy(built_in_controls=[ConditionalAccessGrantControl.MFA]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists_test.py b/tests/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists_test.py new file mode 100644 index 0000000000..5d90ba9ceb --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists_test.py @@ -0,0 +1,98 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import NamedLocation +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_trusted_named_location_exists.entra_conditional_access_trusted_named_location_exists" + + +class Test_entra_conditional_access_trusted_named_location_exists: + def _run(self, named_locations): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_trusted_named_location_exists.entra_conditional_access_trusted_named_location_exists import ( + entra_conditional_access_trusted_named_location_exists, + ) + + entra_client.named_locations = named_locations + return entra_conditional_access_trusted_named_location_exists().execute() + + def test_no_resources(self): + result = self._run([]) + assert len(result) == 0 + + def test_trusted_ip_location(self): + result = self._run( + [ + NamedLocation( + id="loc1", + display_name="Corp", + is_trusted=True, + is_ip_location=True, + ip_ranges_count=2, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_multiple_locations_each_produce_a_report(self): + result = self._run( + [ + NamedLocation( + id="trusted-location", + display_name="Trusted Location", + is_trusted=True, + is_ip_location=True, + ip_ranges_count=1, + ), + NamedLocation( + id="untrusted-location", + display_name="Untrusted Location", + is_trusted=False, + is_ip_location=True, + ip_ranges_count=1, + ), + ] + ) + + assert len(result) == 2 + assert [report.resource_id for report in result] == [ + "trusted-location", + "untrusted-location", + ] + assert [report.status for report in result] == ["PASS", "FAIL"] + + def test_untrusted_ip_location(self): + result = self._run( + [ + NamedLocation( + id="loc1", + is_trusted=False, + is_ip_location=True, + ip_ranges_count=2, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_trusted_but_no_ranges(self): + result = self._run( + [ + NamedLocation( + id="loc1", + is_trusted=True, + is_ip_location=True, + ip_ranges_count=0, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured_test.py b/tests/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured_test.py new file mode 100644 index 0000000000..16817c1d44 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured_test.py @@ -0,0 +1,83 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ActivityBasedTimeoutPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_idle_session_timeout_configured.entra_idle_session_timeout_configured" + + +class Test_entra_idle_session_timeout_configured: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_idle_session_timeout_configured.entra_idle_session_timeout_configured import ( + entra_idle_session_timeout_configured, + ) + + entra_client.activity_based_timeout_policies = policies + return entra_idle_session_timeout_configured().execute() + + def test_no_policies(self): + result = self._run([]) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_within_limit(self): + result = self._run( + [ + ActivityBasedTimeoutPolicy( + id="p1", + display_name="Timeout", + web_session_idle_timeout_seconds=3 * 60 * 60, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_within_limit_preserves_seconds_in_status_text(self): + result = self._run( + [ + ActivityBasedTimeoutPolicy( + id="p1", + display_name="Exact Timeout", + web_session_idle_timeout_seconds=2 * 60 * 60 + 30 * 60 + 45, + ) + ] + ) + assert result[0].status_extended == ( + "Activity-based timeout policy 'Exact Timeout' enforces an idle session " + "timeout of 150 minutes and 45 seconds." + ) + + def test_exceeds_limit(self): + result = self._run( + [ + ActivityBasedTimeoutPolicy( + id="p1", + web_session_idle_timeout_seconds=5 * 60 * 60, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_app_specific_timeout_without_default_fails(self): + result = self._run( + [ + ActivityBasedTimeoutPolicy( + id="p1", + web_session_idle_timeout_seconds=None, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required_test.py b/tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required_test.py new file mode 100644 index 0000000000..8b359f5cd9 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required_test.py @@ -0,0 +1,69 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + PimRoleApprovalSetting, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_pim_global_administrator_approval_required.entra_pim_global_administrator_approval_required" + + +class Test_entra_pim_global_administrator_approval_required: + def _run(self, settings): + entra_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_pim_global_administrator_approval_required.entra_pim_global_administrator_approval_required import ( + entra_pim_global_administrator_approval_required, + ) + + entra_client.pim_role_approval_settings = settings + return entra_pim_global_administrator_approval_required().execute() + + def test_no_setting(self): + assert self._run({}) == [] + + def test_approval_required(self): + result = self._run( + { + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=True, + has_approvers=True, + ) + } + ) + assert result[0].status == "PASS" + + def test_no_approvers(self): + result = self._run( + { + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=True, + has_approvers=False, + ) + } + ) + assert result[0].status == "FAIL" + + def test_approval_not_required_with_approvers(self): + result = self._run( + { + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=False, + has_approvers=True, + ) + } + ) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "PIM does not require approval to activate the Global Administrator role." + ) diff --git a/tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required_test.py b/tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required_test.py new file mode 100644 index 0000000000..8c982cedd4 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required_test.py @@ -0,0 +1,70 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + PimRoleApprovalSetting, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_pim_privileged_role_administrator_approval_required.entra_pim_privileged_role_administrator_approval_required" + + +class Test_entra_pim_privileged_role_administrator_approval_required: + def _run(self, settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_pim_privileged_role_administrator_approval_required.entra_pim_privileged_role_administrator_approval_required import ( + entra_pim_privileged_role_administrator_approval_required, + ) + + entra_client.pim_role_approval_settings = settings + return entra_pim_privileged_role_administrator_approval_required().execute() + + def test_no_setting(self): + assert self._run({}) == [] + + def test_approval_required(self): + result = self._run( + { + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=True, + has_approvers=True, + ) + } + ) + assert result[0].status == "PASS" + + def test_no_approvers(self): + result = self._run( + { + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=True, + has_approvers=False, + ) + } + ) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "PIM requires approval to activate the Privileged Role Administrator " + "role but no approvers are configured." + ) + + def test_not_required(self): + result = self._run( + { + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=False, + has_approvers=True, + ) + } + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index 0efec0cb0d..e38ac5c0e6 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -3,7 +3,9 @@ import importlib import json from datetime import datetime, timezone from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest from prowler.providers.m365.models import M365IdentityInfo from prowler.providers.m365.services.entra import entra_service @@ -40,6 +42,363 @@ UserAction = entra_service.UserAction UsersConditions = entra_service.UsersConditions +def _get_access_review_definitions(definition): + service = Entra.__new__(Entra) + request_information = MagicMock() + service.client = SimpleNamespace( + identity_governance=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock( + return_value=request_information + ) + ) + ) + ), + request_adapter=SimpleNamespace( + send_primitive_async=AsyncMock( + return_value=json.dumps({"value": [definition]}).encode() + ) + ), + ) + return asyncio.run(service._get_access_review_definitions()) + + +class TestAccessReviewDefinitions: + @pytest.mark.parametrize( + ("settings", "expected"), + [ + ({"defaultDecisionEnabled": True}, True), + ({"defaultDecisionEnabled": False}, False), + ({}, False), + ], + ) + def test_parses_default_decision_enabled(self, settings, expected): + definitions = _get_access_review_definitions( + {"id": "review-1", "settings": settings} + ) + + assert definitions[0].default_decision_enabled is expected + + def test_parses_recurrence_and_top_level_reviewers(self): + definitions = _get_access_review_definitions( + { + "id": "review-1", + "reviewers": [{"query": "/users/reviewer-1"}], + "settings": { + "recurrence": { + "pattern": {"type": "weekly"}, + "range": {"type": "noEnd"}, + } + }, + } + ) + + assert definitions[0].recurrence_pattern_type == "weekly" + assert definitions[0].recurrence_range_type == "noEnd" + assert definitions[0].has_primary_reviewers is True + + def test_uses_reviewers_from_every_configured_stage(self): + definitions = _get_access_review_definitions( + { + "id": "review-1", + "reviewers": [], + "stageSettings": [ + {"reviewers": [{"query": "/users/reviewer-1"}]}, + {"reviewers": [{"query": "/users/reviewer-2"}]}, + ], + } + ) + + assert definitions[0].has_primary_reviewers is True + + @pytest.mark.parametrize( + "reviewer_configuration", + [ + {"fallbackReviewers": [{"query": "/users/fallback-1"}]}, + { + "reviewers": [{"query": "/users/top-level-reviewer"}], + "stageSettings": [ + {"reviewers": [{"query": "/users/stage-reviewer"}]}, + {"reviewers": []}, + ], + }, + ], + ) + def test_fallback_or_incomplete_stage_reviewers_do_not_count( + self, reviewer_configuration + ): + definitions = _get_access_review_definitions( + {"id": "review-1", **reviewer_configuration} + ) + + assert definitions[0].has_primary_reviewers is False + + @pytest.mark.parametrize( + "terminal_response", + [b"", json.dumps({"value": []}).encode()], + ) + def test_preserves_definitions_when_terminal_page_is_empty(self, terminal_response): + service = Entra.__new__(Entra) + first_page = json.dumps( + { + "value": [{"id": "review-1", "displayName": "Guest Review"}], + "@odata.nextLink": "next-link", + } + ).encode() + send_mock = AsyncMock(side_effect=[first_page, terminal_response]) + with_url_mock = MagicMock( + side_effect=[ + SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ), + SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-2") + ), + ] + ) + service.client = SimpleNamespace( + identity_governance=SimpleNamespace(with_url=with_url_mock), + request_adapter=SimpleNamespace(send_primitive_async=send_mock), + ) + + definitions = asyncio.run(service._get_access_review_definitions()) + + assert [definition.id for definition in definitions] == ["review-1"] + assert send_mock.await_count == 2 + assert with_url_mock.call_args_list[-1] == call("next-link") + + def test_error_returns_no_access_review_definitions(self): + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + identity_governance=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ) + ) + ), + request_adapter=SimpleNamespace( + send_primitive_async=AsyncMock(side_effect=RuntimeError("Graph error")) + ), + ) + + assert asyncio.run(service._get_access_review_definitions()) == [] + + +class TestPimRoleApprovalSettings: + def test_paginates_and_parses_role_approval_settings(self): + service = Entra.__new__(Entra) + first_page = json.dumps( + { + "value": [ + {"policy": {"rules": []}}, + { + "roleDefinitionId": "role-unrelated", + "policy": {"rules": [{"id": "Unrelated_Rule"}]}, + }, + { + "roleDefinitionId": "role-approval-required", + "policy": { + "rules": [ + { + "id": "Approval_EndUser_Assignment", + "setting": { + "isApprovalRequired": True, + "approvalStages": [ + {"primaryApprovers": []}, + { + "primaryApprovers": [ + {"id": "approver-1"} + ] + }, + ], + }, + } + ] + }, + }, + ], + "@odata.nextLink": "next-link", + } + ).encode() + final_page = json.dumps( + { + "value": [ + { + "roleDefinitionId": "role-approval-disabled", + "policy": { + "rules": [ + { + "id": "Approval_EndUser_Assignment", + "setting": { + "isApprovalRequired": False, + "approvalStages": [{"primaryApprovers": []}], + }, + } + ] + }, + } + ] + } + ).encode() + send_mock = AsyncMock(side_effect=[first_page, final_page]) + with_url_mock = MagicMock( + side_effect=[ + SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ), + SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-2") + ), + ] + ) + service.client = SimpleNamespace( + policies=SimpleNamespace(with_url=with_url_mock), + request_adapter=SimpleNamespace(send_primitive_async=send_mock), + ) + + settings = asyncio.run(service._get_pim_role_approval_settings()) + + assert set(settings) == { + "role-unrelated", + "role-approval-required", + "role-approval-disabled", + } + assert settings["role-unrelated"].is_approval_required is False + assert settings["role-unrelated"].has_approvers is False + assert settings["role-approval-required"].is_approval_required is True + assert settings["role-approval-required"].has_approvers is True + assert settings["role-approval-disabled"].is_approval_required is False + assert settings["role-approval-disabled"].has_approvers is False + assert send_mock.await_count == 2 + assert with_url_mock.call_args_list[-1] == call("next-link") + + @pytest.mark.parametrize( + "response", + [b"", json.dumps({"value": []}).encode()], + ) + def test_stops_on_falsy_or_empty_response(self, response): + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ) + ) + ), + request_adapter=SimpleNamespace( + send_primitive_async=AsyncMock(return_value=response) + ), + ) + + assert asyncio.run(service._get_pim_role_approval_settings()) == {} + + def test_error_returns_no_role_approval_settings(self): + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ) + ) + ), + request_adapter=SimpleNamespace( + send_primitive_async=AsyncMock(side_effect=RuntimeError("Graph error")) + ), + ) + + assert asyncio.run(service._get_pim_role_approval_settings()) == {} + + +class TestAuthenticationMethodsPolicySettings: + @staticmethod + def _service(response=None, error=None): + send_mock = AsyncMock(return_value=response, side_effect=error) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + authentication_methods_policy=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock( + return_value="request-1" + ) + ) + ) + ) + ), + request_adapter=SimpleNamespace(send_primitive_async=send_mock), + ) + return service + + def test_parses_microsoft_authenticator_after_unrelated_configuration(self): + response = json.dumps( + { + "authenticationMethodConfigurations": [ + {"id": "Fido2", "state": "enabled"}, + { + "id": "MicrosoftAuthenticator", + "state": "enabled", + "featureSettings": { + "displayAppInformationRequiredState": {"state": "enabled"}, + "displayLocationInformationRequiredState": { + "state": "disabled" + }, + }, + }, + ] + } + ).encode() + + settings = asyncio.run( + self._service( + response=response + )._get_authentication_methods_policy_settings() + ) + + assert settings.authenticator_state == "enabled" + assert settings.authenticator_display_app_information_state == "enabled" + assert settings.authenticator_display_location_information_state == "disabled" + + def test_missing_microsoft_authenticator_returns_unconfigured_model(self): + response = json.dumps( + {"authenticationMethodConfigurations": [{"id": "Fido2"}]} + ).encode() + + settings = asyncio.run( + self._service( + response=response + )._get_authentication_methods_policy_settings() + ) + + assert settings.authenticator_state is None + assert settings.authenticator_display_app_information_state is None + assert settings.authenticator_display_location_information_state is None + + def test_falsy_response_returns_none(self): + assert ( + asyncio.run( + self._service( + response=b"" + )._get_authentication_methods_policy_settings() + ) + is None + ) + + def test_error_returns_none(self): + assert ( + asyncio.run( + self._service( + error=RuntimeError("Graph error") + )._get_authentication_methods_policy_settings() + ) + is None + ) + + async def mock_entra_get_authorization_policy(_): return AuthorizationPolicy( id="id-1", @@ -275,6 +634,25 @@ class Test_Entra_Service: == AuthPolicyRoles.GUEST_USER_ACCESS_RESTRICTED.value ) + @pytest.mark.parametrize( + "value", + ["-00:00:01", "-1.00:00:00", "24:00:00", "00:60:00", "00:00:60"], + ) + def test_parse_timespan_rejects_invalid_component_ranges(self, value): + assert Entra._parse_timespan_to_seconds(value) is None + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("00:00:00", 0), + ("23:59:59", 86399), + ("1.00:00:00", 86400), + ("00:00:01.9999999", 1), + ], + ) + def test_parse_timespan_accepts_valid_component_boundaries(self, value, expected): + assert Entra._parse_timespan_to_seconds(value) == expected + @patch( "prowler.providers.m365.services.entra.entra_service.Entra._get_conditional_access_policies", new=mock_entra_get_conditional_access_policies, @@ -1301,3 +1679,275 @@ class Test_Entra_Service: queried_users = {call.args[0] for call in by_user_id.call_args_list} assert queried_users == {deleted_user, live_user} assert user_builders[deleted_user].get.await_count == 1 + + def test__get_named_locations_paginates_through_next_links(self): + entra_service = Entra.__new__(Entra) + + page_one = json.dumps( + { + "value": [ + { + "@odata.type": "#microsoft.graph.ipNamedLocation", + "id": "loc-1", + "displayName": "Trusted IPs", + "isTrusted": True, + "ipRanges": [{"cidrAddress": "10.0.0.0/8"}], + } + ], + "@odata.nextLink": "next-link", + } + ) + page_two = json.dumps( + { + "value": [ + { + "@odata.type": "#microsoft.graph.countryNamedLocation", + "id": "loc-2", + "displayName": "Countries", + "isTrusted": False, + } + ] + } + ) + + send_mock = AsyncMock(side_effect=[page_one, page_two]) + next_link_builder = SimpleNamespace( + to_get_request_information=MagicMock(return_value="req-info-page-two") + ) + with_url_mock = MagicMock(return_value=next_link_builder) + named_locations_builder = SimpleNamespace( + to_get_request_information=MagicMock(return_value="req-info-page-one"), + with_url=with_url_mock, + ) + entra_service.client = SimpleNamespace( + identity=SimpleNamespace( + conditional_access=SimpleNamespace( + named_locations=named_locations_builder + ) + ), + request_adapter=SimpleNamespace(send_primitive_async=send_mock), + ) + + named_locations = asyncio.run(entra_service._get_named_locations()) + + # Both pages are requested and every entry is accumulated before parsing. + assert send_mock.await_count == 2 + with_url_mock.assert_called_once_with("next-link") + assert [loc.id for loc in named_locations] == ["loc-1", "loc-2"] + assert named_locations[0].is_ip_location is True + assert named_locations[0].ip_ranges_count == 1 + assert named_locations[1].is_ip_location is False + assert named_locations[1].is_trusted is False + + def test__get_named_locations_continues_after_empty_page(self): + entra_service = Entra.__new__(Entra) + page_one = json.dumps({"value": [], "@odata.nextLink": "next-link"}) + page_two = json.dumps( + { + "value": [ + { + "@odata.type": "#microsoft.graph.ipNamedLocation", + "id": "loc-2", + "displayName": "Trusted IPs", + "isTrusted": True, + "ipRanges": [{"cidrAddress": "10.0.0.0/8"}], + } + ] + } + ) + send_mock = AsyncMock(side_effect=[page_one, page_two]) + next_link_builder = SimpleNamespace( + to_get_request_information=MagicMock(return_value="req-info-page-two") + ) + with_url_mock = MagicMock(return_value=next_link_builder) + entra_service.client = SimpleNamespace( + identity=SimpleNamespace( + conditional_access=SimpleNamespace( + named_locations=SimpleNamespace( + to_get_request_information=MagicMock( + return_value="req-info-page-one" + ), + with_url=with_url_mock, + ) + ) + ), + request_adapter=SimpleNamespace(send_primitive_async=send_mock), + ) + + named_locations = asyncio.run(entra_service._get_named_locations()) + + assert send_mock.await_count == 2 + with_url_mock.assert_called_once_with("next-link") + assert [location.id for location in named_locations] == ["loc-2"] + + def test__get_activity_based_timeout_policies_uses_default_application(self): + entra_service = Entra.__new__(Entra) + policy = SimpleNamespace( + id="policy-1", + display_name="Idle timeout", + definition=[ + json.dumps( + { + "ActivityBasedTimeoutPolicy": { + "ApplicationPolicies": [ + { + "ApplicationId": "default", + "WebSessionIdleTimeout": "05:00:00", + }, + { + "ApplicationId": "00000000-0000-0000-0000-000000000001", + "WebSessionIdleTimeout": "01:00:00", + }, + ] + } + } + ) + ], + ) + entra_service.client = SimpleNamespace( + policies=SimpleNamespace( + activity_based_timeout_policies=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + value=[policy], odata_next_link=None + ) + ), + with_url=MagicMock(), + ) + ) + ) + + policies = asyncio.run(entra_service._get_activity_based_timeout_policies()) + + assert len(policies) == 1 + assert policies[0].web_session_idle_timeout_seconds == 5 * 60 * 60 + + def test__get_activity_based_timeout_policies_without_default_is_unconfigured(self): + entra_service = Entra.__new__(Entra) + policy = SimpleNamespace( + id="policy-1", + display_name="App-specific timeout", + definition=[ + json.dumps( + { + "ActivityBasedTimeoutPolicy": { + "ApplicationPolicies": [ + { + "ApplicationId": "00000000-0000-0000-0000-000000000001", + "WebSessionIdleTimeout": "01:00:00", + } + ] + } + } + ) + ], + ) + entra_service.client = SimpleNamespace( + policies=SimpleNamespace( + activity_based_timeout_policies=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + value=[policy], odata_next_link=None + ) + ), + with_url=MagicMock(), + ) + ) + ) + + policies = asyncio.run(entra_service._get_activity_based_timeout_policies()) + + assert len(policies) == 1 + assert policies[0].web_session_idle_timeout_seconds is None + + def test__get_activity_based_timeout_policies_uses_first_valid_default(self): + entra_service = Entra.__new__(Entra) + + def definition(timeout): + return json.dumps( + { + "ActivityBasedTimeoutPolicy": { + "ApplicationPolicies": [ + { + "ApplicationId": "default", + "WebSessionIdleTimeout": timeout, + } + ] + } + } + ) + + policy = SimpleNamespace( + id="policy-1", + display_name="Idle timeout", + definition=[ + "invalid-json", + definition("invalid-timeout"), + definition("01:00:00"), + definition("02:00:00"), + ], + ) + entra_service.client = SimpleNamespace( + policies=SimpleNamespace( + activity_based_timeout_policies=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + value=[policy], odata_next_link=None + ) + ), + with_url=MagicMock(), + ) + ) + ) + + policies = asyncio.run(entra_service._get_activity_based_timeout_policies()) + + assert len(policies) == 1 + assert policies[0].web_session_idle_timeout_seconds == 60 * 60 + + def test__get_activity_based_timeout_policies_paginates_through_next_links(self): + entra_service = Entra.__new__(Entra) + + def policy(policy_id, timeout): + return SimpleNamespace( + id=policy_id, + display_name=policy_id, + definition=[ + json.dumps( + { + "ActivityBasedTimeoutPolicy": { + "ApplicationPolicies": [ + { + "ApplicationId": "default", + "WebSessionIdleTimeout": timeout, + } + ] + } + } + ) + ], + ) + + page_one = SimpleNamespace( + value=[policy("policy-1", "01:00:00")], + odata_next_link="next-link", + ) + page_two = SimpleNamespace( + value=[policy("policy-2", "02:00:00")], + odata_next_link=None, + ) + next_page_builder = SimpleNamespace(get=AsyncMock(return_value=page_two)) + with_url_mock = MagicMock(return_value=next_page_builder) + policies_builder = SimpleNamespace( + get=AsyncMock(return_value=page_one), + with_url=with_url_mock, + ) + entra_service.client = SimpleNamespace( + policies=SimpleNamespace(activity_based_timeout_policies=policies_builder) + ) + + policies = asyncio.run(entra_service._get_activity_based_timeout_policies()) + + assert [policy.id for policy in policies] == ["policy-1", "policy-2"] + with_url_mock.assert_called_once_with("next-link") + next_page_builder.get.assert_awaited_once() diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8f59e42a59..6486d56867 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.39.0] (Prowler v5.39.0) + +### 🚀 Added + +- Manual verification workflow for `MANUAL` findings with evidence, effective `PASS` status, and expiration details [(#12253)](https://github.com/prowler-cloud/prowler/pull/12253) +- Surface pre-configured credential creation links in the add-provider wizard. Cloudflare exposes the User API Token template and an Account-Owned template pinned to the Cloudflare Account ID entered in the wizard, GitHub exposes the personal-repositories template and an organization-scanning template pinned to the identifier entered in the wizard [(#12349)](https://github.com/prowler-cloud/prowler/pull/12349) +- Attack Paths graph groups resources by class into expandable nodes and marks the query outcome as the terminal node, with the clicked resource highlighted while its findings are expanded (Prowler Cloud only) [(#12381)](https://github.com/prowler-cloud/prowler/pull/12381) +- Azure Management Group onboarding: add every subscription in a tenant at once (Prowler Cloud only) [(#12386)](https://github.com/prowler-cloud/prowler/pull/12386) +- Manage Lighthouse AI role permission in the role forms and role details, so permission to change the Lighthouse AI configuration can be granted or restricted independently of other permissions (Prowler Cloud only) [(#12412)](https://github.com/prowler-cloud/prowler/pull/12412) +- CMMC 2.0 universal compliance framework rendering: dedicated icon, Domain/Level requirement mapper and cross-provider catalog tile [(#12414)](https://github.com/prowler-cloud/prowler/pull/12414) + +### 🐞 Fixed + +- Organization discovery describes a too-deep hierarchy in each provider's own vocabulary: AWS organizational units, Azure Management Groups, Google Cloud folders [(#12386)](https://github.com/prowler-cloud/prowler/pull/12386) +- `View Findings` on the Scans page no longer opens an empty list for users outside the UTC timezone [(#12411)](https://github.com/prowler-cloud/prowler/pull/12411) + +--- + ## [1.38.0] (Prowler v5.38.0) ### 🚀 Added diff --git a/ui/__tests__/msw/handlers/organizations.fixtures.ts b/ui/__tests__/msw/handlers/organizations.fixtures.ts index 80c16ba8df..516c522754 100644 --- a/ui/__tests__/msw/handlers/organizations.fixtures.ts +++ b/ui/__tests__/msw/handlers/organizations.fixtures.ts @@ -15,6 +15,8 @@ import { ORGANIZATION_TYPE } from "@/types/organizations"; import type { + AzureDiscoveredSubscription, + AzureDiscoveryResult, GcpDiscoveredProject, GcpDiscoveryResult, } from "@/types/organizations"; @@ -29,10 +31,14 @@ export const DISCOVERY_STATUS_VALUE = { export type DiscoveryStatusValue = (typeof DISCOVERY_STATUS_VALUE)[keyof typeof DISCOVERY_STATUS_VALUE]; -/** Canonical node kinds (AWS organizational unit, GCP folder). */ +/** + * Canonical node kinds (AWS organizational unit, GCP folder, Azure management + * group). All kebab-case: `toNodeKind` rejects any other spelling. + */ export const NODE_KIND = { ORGANIZATIONAL_UNIT: "organizational-unit", FOLDER: "folder", + MANAGEMENT_GROUP: "management-group", } as const; export type NodeKind = (typeof NODE_KIND)[keyof typeof NODE_KIND]; @@ -133,9 +139,12 @@ export interface FixtureApplyOutcome { export interface FixtureDiscovery { id: string; status: DiscoveryStatusValue; - /** Raw AWS or GCP discovery result served on the discovery poll. */ + /** Raw AWS, Azure or GCP discovery result served on the discovery poll. */ result: unknown; + /** Machine error code — never user copy. */ error: string | null; + /** Sanitized human message the server sends alongside the code, when it has one. */ + errorMessage?: string | null; } export interface FixtureScheduleBulkOutcome { @@ -156,7 +165,10 @@ export interface OrgFixture { providers: FixtureProvider[]; discovery: FixtureDiscovery | null; apply: FixtureApplyOutcome; - /** Connection outcomes keyed by provider uid (AWS account id / GCP project). */ + /** + * Connection outcomes keyed by provider uid (AWS account id, GCP project id, + * Azure subscription id). + */ connectionByUid: Record; /** POST /organization-secrets returns 409 (duplicate). */ duplicateSecret: boolean; @@ -426,6 +438,189 @@ export const buildGcpDiscoveryResult = ({ }; }; +// --- Azure discovery result ------------------------------------------------ + +/** Canonical Management Group resource ID, the only identity Azure parents on. */ +const azureGroupId = (name: string) => + `/providers/Microsoft.Management/managementGroups/${name}`; + +export const AZURE_TENANT_ID = "11111111-1111-4111-8111-111111111111"; +/** Tenant root group — the target the setup form defaults to. */ +export const AZURE_ROOT_GROUP = azureGroupId(AZURE_TENANT_ID); +export const AZURE_GROUP_ENGINEERING = azureGroupId("engineering"); +export const AZURE_GROUP_PLATFORM = azureGroupId("platform"); +/** + * The two kinds of Management Group with nothing selectable in them: no + * subscriptions at all, and only blocked ones. Neither changes the selectable + * count, and both must still expand. + */ +export const AZURE_EMPTY_GROUP = azureGroupId("holding"); +export const AZURE_EMPTY_GROUP_NAME = "Holding"; +export const AZURE_BLOCKED_GROUP = azureGroupId("archived"); +export const AZURE_BLOCKED_GROUP_NAME = "Archived"; + +export const AZURE_SUBSCRIPTION_PROD_EU = + "22222222-2222-4222-8222-222222222222"; +export const AZURE_SUBSCRIPTION_PROD_US = + "33333333-3333-4333-8333-333333333333"; +/** Blocked, and hanging directly off the root group (`not_applicable` node relation). */ +export const AZURE_SUBSCRIPTION_LEGACY = "55555555-5555-4555-8555-555555555555"; +export const AZURE_BLOCKED_GROUP_SUBSCRIPTION = + "44444444-4444-4444-8444-444444444444"; +/** + * Blocked purely because it is not enabled — no provider exists and no linkage + * conflict applies, the one blocked class Azure can raise on its own. + */ +export const AZURE_SUBSCRIPTION_DISABLED = + "66666666-6666-4666-8666-666666666666"; + +/** + * Blocked reasons Azure discovery reports: GCP's `*_conflict` vocabulary for the + * three linkage/type conflicts, plus its own `subscription_not_enabled`, raised + * whenever `state != "Enabled"`. + */ +export const AZURE_BLOCKED_REASON = { + ORGANIZATION: "organization_conflict", + ORGANIZATION_NODE: "organization_node_conflict", + PROVIDER_TYPE: "provider_type_conflict", + NOT_ENABLED: "subscription_not_enabled", +} as const; + +interface AzureResultOverrides { + /** Subscription ids whose registration reports `will_replace`. */ + replaceSubscriptionIds?: string[]; +} + +/** Pinned to the app's wire interfaces, for the same reason `GcpFixtureDiscoveryResult` is. */ +type AzureFixtureDiscoveryResult = Omit< + AzureDiscoveryResult, + "subscriptions" +> & { + subscriptions: (Omit & { + registration: FixtureRegistration; + })[]; +}; + +/** + * The Azure discovery result as the API shapes it: groups carry canonical resource + * IDs in `id`/`parent_id`, subscriptions are identified by their UUID. Those UUIDs + * are long by nature, so the id-column overflow case needs no special candidate. + */ +export const buildAzureDiscoveryResult = ({ + replaceSubscriptionIds = [], +}: AzureResultOverrides = {}): AzureFixtureDiscoveryResult => { + const subscription = ( + subscriptionId: string, + displayName: string, + parentId: string, + registration: FixtureRegistration, + state = "Enabled", + ) => ({ + subscription_id: subscriptionId, + display_name: displayName, + state, + parent_id: parentId, + registration, + }); + + const readyRegFor = (subscriptionId: string): FixtureRegistration => + replaceSubscriptionIds.includes(subscriptionId) + ? readyRegistration({ + provider_exists: true, + provider_id: `provider-existing-${subscriptionId}`, + provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE, + }) + : readyRegistration(); + + return { + root_management_group: { + id: AZURE_ROOT_GROUP, + name: AZURE_TENANT_ID, + display_name: "Tenant Root Group", + tenant_id: AZURE_TENANT_ID, + }, + management_groups: [ + { + id: AZURE_GROUP_ENGINEERING, + name: "engineering", + display_name: "Engineering", + parent_id: AZURE_ROOT_GROUP, + }, + { + id: AZURE_GROUP_PLATFORM, + name: "platform", + display_name: "Platform", + parent_id: AZURE_GROUP_ENGINEERING, + }, + { + id: AZURE_EMPTY_GROUP, + name: "holding", + display_name: AZURE_EMPTY_GROUP_NAME, + parent_id: AZURE_ROOT_GROUP, + }, + { + id: AZURE_BLOCKED_GROUP, + name: "archived", + display_name: AZURE_BLOCKED_GROUP_NAME, + parent_id: AZURE_ROOT_GROUP, + }, + ], + subscriptions: [ + subscription( + AZURE_SUBSCRIPTION_PROD_EU, + "Production EU", + AZURE_GROUP_ENGINEERING, + readyRegFor(AZURE_SUBSCRIPTION_PROD_EU), + ), + subscription( + AZURE_SUBSCRIPTION_PROD_US, + "Production US", + AZURE_GROUP_PLATFORM, + readyRegFor(AZURE_SUBSCRIPTION_PROD_US), + ), + subscription( + AZURE_SUBSCRIPTION_LEGACY, + "Legacy Sandbox", + AZURE_ROOT_GROUP, + blockedRegistration([AZURE_BLOCKED_REASON.ORGANIZATION], { + provider_exists: true, + provider_id: `provider-existing-${AZURE_SUBSCRIPTION_LEGACY}`, + organization_relation: "linked_to_other_organization", + organization_node_relation: "not_applicable", + provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE, + }), + ), + subscription( + AZURE_SUBSCRIPTION_DISABLED, + "Dormant Sandbox", + AZURE_ROOT_GROUP, + blockedRegistration([AZURE_BLOCKED_REASON.NOT_ENABLED], { + organization_node_relation: "not_applicable", + }), + "Disabled", + ), + subscription( + AZURE_BLOCKED_GROUP_SUBSCRIPTION, + "Archived Legacy", + AZURE_BLOCKED_GROUP, + blockedRegistration( + [ + AZURE_BLOCKED_REASON.ORGANIZATION, + AZURE_BLOCKED_REASON.ORGANIZATION_NODE, + ], + { + provider_exists: true, + provider_id: `provider-existing-${AZURE_BLOCKED_GROUP_SUBSCRIPTION}`, + organization_relation: "linked_to_other_organization", + organization_node_relation: "linked_to_other_node", + provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE, + }, + ), + ), + ], + }; +}; + // --- Fixture builders ------------------------------------------------------ /** @@ -441,6 +636,10 @@ export const GCP_CREATED_PROVIDER_IDS = [ "bbbbbbb1-1111-4111-8111-111111111111", "bbbbbbb2-2222-4222-8222-222222222222", ]; +export const AZURE_CREATED_PROVIDER_IDS = [ + "ccccccc1-1111-4111-8111-111111111111", + "ccccccc2-2222-4222-8222-222222222222", +]; const emptyApply = (): FixtureApplyOutcome => ({ createdProviderIds: [], @@ -535,6 +734,51 @@ export const gcpOnboardingFixture = ( }; }; +/** + * A fresh Azure organization onboarding world (management groups + + * subscriptions). Selection defaults to the two ready subscriptions; the + * remaining three are blocked, one of them inside an otherwise empty Management + * Group. + */ +export const azureOnboardingFixture = ( + overrides: Partial = {}, +): OrgFixture => { + const createdProviderIds = AZURE_CREATED_PROVIDER_IDS; + return { + ...baseFixture(), + discovery: { + id: "disc-azure-1", + status: DISCOVERY_STATUS_VALUE.SUCCEEDED, + result: buildAzureDiscoveryResult(), + error: null, + }, + apply: { + ...emptyApply(), + createdProviderIds, + providersCreatedCount: 2, + nodesCreatedCount: 2, + candidateProviderIds: [ + { + candidateId: AZURE_SUBSCRIPTION_PROD_EU, + providerId: createdProviderIds[0], + }, + { + candidateId: AZURE_SUBSCRIPTION_PROD_US, + providerId: createdProviderIds[1], + }, + ], + }, + connectionByUid: { + [AZURE_SUBSCRIPTION_PROD_EU]: { connected: true }, + [AZURE_SUBSCRIPTION_PROD_US]: { connected: true }, + }, + ...overrides, + }; +}; + +/** The AWS organization identifier of `awsHierarchyFixture`. */ +export const AWS_HIERARCHY_ORG_EXTERNAL_ID = "o-aws0abcdef"; + /** * A providers-page hierarchy world with a fully onboarded AWS organization * (two OUs, three providers). Used for the providers-table grouping tests. @@ -593,7 +837,7 @@ export const awsHierarchyFixture = ( id: orgId, orgType: ORGANIZATION_TYPE.AWS, name: "My AWS Organization", - externalId: "o-aws0abcdef", + externalId: AWS_HIERARCHY_ORG_EXTERNAL_ID, rootExternalId: AWS_ROOT_ID, providerIds: [], nodeIds: nodes.map((n) => n.id), @@ -606,11 +850,63 @@ export const awsHierarchyFixture = ( }; }; -/** AWS + GCP organizations side by side (mixed-hierarchy display test). */ +/** + * Management Groups of an already-onboarded Azure organization. Named apart from + * the discovery fixture's groups (and from the AWS/GCP containers) so a row + * lookup by label can only resolve to one hierarchy. + */ +const AZURE_HIERARCHY_GROUP = azureGroupId("landing-zones"); +export const AZURE_HIERARCHY_GROUP_NAME = "Landing Zones"; +const AZURE_HIERARCHY_CHILD_GROUP = azureGroupId("decommissioned"); +export const AZURE_HIERARCHY_CHILD_GROUP_NAME = "Decommissioned"; +/** The Azure organization of `mixedHierarchyFixture`, and its Management Group node. */ +export const AZURE_ORG_NAME = "My Azure Organization"; +export const AZURE_GROUP_NODE_ID = "node-azure-lz"; + +/** AWS + Azure + GCP organizations side by side (mixed-hierarchy display test). */ export const mixedHierarchyFixture = ( overrides: Partial = {}, ): OrgFixture => { const aws = awsHierarchyFixture(); + const azureOrgId = "org-azure-1"; + const azureProviders: FixtureProvider[] = [ + { + id: "azp-1", + provider: "azure", + uid: AZURE_SUBSCRIPTION_PROD_EU, + alias: "Contoso EU", + connected: true, + }, + { + id: "azp-2", + provider: "azure", + uid: AZURE_SUBSCRIPTION_PROD_US, + alias: "Contoso US", + connected: true, + }, + ]; + const azureNodes: FixtureNode[] = [ + { + id: AZURE_GROUP_NODE_ID, + kind: NODE_KIND.MANAGEMENT_GROUP, + name: AZURE_HIERARCHY_GROUP_NAME, + externalId: AZURE_HIERARCHY_GROUP, + // The tenant-root Management Group is never persisted as a node, so this + // parent id deliberately resolves to no node row. + parentExternalId: AZURE_ROOT_GROUP, + organizationId: azureOrgId, + providerIds: ["azp-1"], + }, + { + id: "node-azure-decommissioned", + kind: NODE_KIND.MANAGEMENT_GROUP, + name: AZURE_HIERARCHY_CHILD_GROUP_NAME, + externalId: AZURE_HIERARCHY_CHILD_GROUP, + parentExternalId: AZURE_HIERARCHY_GROUP, + organizationId: azureOrgId, + providerIds: ["azp-2"], + }, + ]; const gcpOrgId = "org-gcp-1"; const gcpProviders: FixtureProvider[] = [ { @@ -652,6 +948,18 @@ export const mixedHierarchyFixture = ( ...baseFixture(), organizations: [ ...aws.organizations, + { + id: azureOrgId, + orgType: ORGANIZATION_TYPE.AZURE, + name: AZURE_ORG_NAME, + externalId: AZURE_TENANT_ID, + // Azure is the one type that writes its own root: the Management Group + // the organization is scoped to. + rootExternalId: AZURE_ROOT_GROUP, + providerIds: [], + nodeIds: azureNodes.map((n) => n.id), + secretId: "secret-azure-1", + }, { id: gcpOrgId, orgType: ORGANIZATION_TYPE.GCP, @@ -665,12 +973,22 @@ export const mixedHierarchyFixture = ( secretId: "secret-gcp-1", }, ], - nodes: [...aws.nodes, ...gcpNodes], - providers: [...aws.providers, ...gcpProviders], + nodes: [...aws.nodes, ...azureNodes, ...gcpNodes], + providers: [...aws.providers, ...azureProviders, ...gcpProviders], ...overrides, }; }; +/** + * An `org_type` this build has no onboarding flow for. Every `ORGANIZATION_TYPE` + * value is onboardable now, so this has to come from outside the enum — which + * mirrors the real case, the enum tracking a server-side one. `oraclecloud` is a + * real provider type, so its provider rows still render coherently. + */ +export const DISPLAY_ONLY_ORG_TYPE = "oraclecloud"; +export const DISPLAY_ONLY_ORG_NAME = "My Oracle Cloud Tenancy"; +export const DISPLAY_ONLY_PROVIDER_ALIAS = "oci-prod"; + /** * An organization of a type the wizard cannot onboard (display-only): it is still * grouped and labelled from its own `org_type`, but offers no wizard re-entry. @@ -678,17 +996,17 @@ export const mixedHierarchyFixture = ( export const displayOnlyOrgHierarchyFixture = ( overrides: Partial = {}, ): OrgFixture => { - const orgId = "org-azure-1"; + const orgId = "org-display-only-1"; return { ...baseFixture(), organizations: [ { id: orgId, - orgType: ORGANIZATION_TYPE.AZURE, - name: "Contoso Tenant", - externalId: "11111111-2222-3333-4444-555555555555", + orgType: DISPLAY_ONLY_ORG_TYPE, + name: DISPLAY_ONLY_ORG_NAME, + externalId: "ocid1.tenancy.oc1..aaaa1111", rootExternalId: null, - providerIds: ["ap-1"], + providerIds: ["op-1"], nodeIds: [], secretId: null, }, @@ -696,10 +1014,10 @@ export const displayOnlyOrgHierarchyFixture = ( nodes: [], providers: [ { - id: "ap-1", - provider: "azure", - uid: "99999999-8888-7777-6666-555555555555", - alias: "contoso-prod", + id: "op-1", + provider: DISPLAY_ONLY_ORG_TYPE, + uid: "ocid1.compartment.oc1..bbbb2222", + alias: DISPLAY_ONLY_PROVIDER_ALIAS, connected: true, }, ], @@ -709,6 +1027,7 @@ export const displayOnlyOrgHierarchyFixture = ( export const fixtures = { awsOnboarding: awsOnboardingFixture, + azureOnboarding: azureOnboardingFixture, gcpOnboarding: gcpOnboardingFixture, awsHierarchy: awsHierarchyFixture, mixedHierarchy: mixedHierarchyFixture, diff --git a/ui/__tests__/msw/handlers/organizations.ts b/ui/__tests__/msw/handlers/organizations.ts index fcab07f0ff..2755e9281c 100644 --- a/ui/__tests__/msw/handlers/organizations.ts +++ b/ui/__tests__/msw/handlers/organizations.ts @@ -347,7 +347,11 @@ export const handlersForOrganizations = ( orgType: String(attrs.org_type ?? "aws"), name: String(attrs.name ?? ""), externalId: String(attrs.external_id ?? ""), - rootExternalId: null, + // Writable on POST for Azure (the target Management Group); AWS and GCP + // leave it to discovery and send nothing. + rootExternalId: attrs.root_external_id + ? String(attrs.root_external_id) + : null, providerIds: [], nodeIds: [], secretId: null, @@ -503,6 +507,8 @@ export const handlersForOrganizations = ( result: fx.discovery.status === "succeeded" ? fx.discovery.result : {}, error: fx.discovery.error, + // Machine code and human message are separate fields on the wire. + error_message: fx.discovery.errorMessage ?? null, inserted_at: TS, updated_at: TS, }, diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts index f9fd0f5b64..9b8b44e05b 100644 --- a/ui/actions/auth/auth.test.ts +++ b/ui/actions/auth/auth.test.ts @@ -21,7 +21,36 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ addAuthEvent: vi.fn(), })); -import { createNewUser } from "./auth"; +import { createNewUser, getUserByMe } from "./auth"; + +const userMeResponse = (roleAttributes: Record) => ({ + data: { + type: "users", + id: "019b1234-5678-7abc-9def-0123456789ab", + attributes: { + name: "Jane Doe", + email: "jane@example.com", + company_name: "Prowler", + date_joined: "2026-01-01T00:00:00.000Z", + }, + }, + included: [ + { + type: "roles", + id: "role-1", + attributes: { name: "Cloud admin", ...roleAttributes }, + }, + ], +}); + +const mockUserMe = (roleAttributes: Record) => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify(userMeResponse(roleAttributes)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); +}; describe("auth actions", () => { beforeEach(() => { @@ -102,4 +131,27 @@ describe("auth actions", () => { expect(requestUrl.searchParams.get("promo_code")).toBe("black-hat-2026"); expect(requestUrl.searchParams.get("utm_source")).toBe("blackhat"); }); + + it("should carry manage_lighthouse_ai_configuration into the session permissions", async () => { + // Given + mockUserMe({ manage_lighthouse_ai_configuration: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_lighthouse_ai_configuration).toBe(true); + }); + + it("should default manage_lighthouse_ai_configuration to false when the role omits it", async () => { + // Given + mockUserMe({ manage_users: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_lighthouse_ai_configuration).toBe(false); + expect(result.permissions.manage_users).toBe(true); + }); }); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 37eb3f6b5c..646c3d1426 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -181,6 +181,8 @@ export const getUserByMe = async (accessToken: string) => { manage_integrations: userRole.attributes.manage_integrations || false, manage_billing: userRole.attributes.manage_billing || false, manage_alerts: userRole.attributes.manage_alerts || false, + manage_lighthouse_ai_configuration: + userRole.attributes.manage_lighthouse_ai_configuration || false, unlimited_visibility: userRole.attributes.unlimited_visibility || false, }; diff --git a/ui/actions/findings/findings-triage.adapter.test.ts b/ui/actions/findings/findings-triage.adapter.test.ts index c84500677c..3095ab3cb5 100644 --- a/ui/actions/findings/findings-triage.adapter.test.ts +++ b/ui/actions/findings/findings-triage.adapter.test.ts @@ -160,6 +160,57 @@ describe("adaptLatestFindingTriageNote", () => { }); describe("adaptFindingTriageSummariesResponse", () => { + it("should keep Resolved as triage while exposing effective Pass and manual provenance", () => { + // Given + const input = { + data: [ + { + id: "finding-manual-1", + type: "findings", + attributes: { + uid: "prowler-finding-manual-uid-1", + status: "PASS", + raw_status: "MANUAL", + triage_status: FINDING_TRIAGE_STATUS.RESOLVED, + manual_pass_created_at: "2026-06-03T10:00:00Z", + manual_pass_expires_at: "2026-09-01T10:00:00Z", + }, + }, + ], + }; + + // When + const [summary] = adaptFindingTriageSummariesResponse(input); + + // Then + expect(summary.rawFindingStatus).toBe("MANUAL"); + expect(input.data[0].attributes.status).toBe("PASS"); + expect(summary.label).toBe("Resolved"); + expect(summary.manualPassProvenance).toBe("Manually verified"); + }); + + it("should keep natural Pass free of manual provenance", () => { + const input = { + data: [ + { + id: "finding-natural-pass", + type: "findings", + attributes: { + uid: "natural-pass-uid", + status: "PASS", + raw_status: "PASS", + triage_status: FINDING_TRIAGE_STATUS.RESOLVED, + }, + }, + ], + }; + + const [summary] = adaptFindingTriageSummariesResponse(input); + + expect(summary.rawFindingStatus).toBe("PASS"); + expect(summary.manualPassProvenance).toBeNull(); + }); + it("should return [] when the provisional API response is malformed", () => { // Given const input = { meta: { count: 0 } }; @@ -380,6 +431,67 @@ describe("adaptFindingTriageSummariesResponse", () => { }); describe("adaptFindingTriageDetailResponse", () => { + it("should map expired Manual Pass metadata for provenance presentation", () => { + // Given + const input = { + data: { + id: "triage-expired-1", + type: "finding-triages", + attributes: { + finding_id: "finding-expired-1", + finding_uid: "prowler-finding-expired-uid-1", + status: FINDING_TRIAGE_STATUS.OPEN, + raw_finding_status: "MANUAL", + manual_pass_active: false, + manual_pass_evidence: "The control owner verified the requirement.", + manual_pass_created_by_name: "Alex Security", + manual_pass_created_at: "2026-06-03T10:00:00Z", + manual_pass_expires_at: "2026-06-17T10:00:00Z", + manual_pass_deactivated_at: null, + }, + }, + }; + + // When + const detail = adaptFindingTriageDetailResponse(input); + + // Then + expect(detail).toEqual( + expect.objectContaining({ + status: FINDING_TRIAGE_STATUS.OPEN, + manualPassActive: false, + manualPassEvidence: "The control owner verified the requirement.", + manualPassCreatedByName: "Alex Security", + manualPassCreatedAt: "2026-06-03T10:00:00Z", + manualPassExpiresAt: "2026-06-17T10:00:00Z", + manualPassDeactivatedAt: null, + }), + ); + }); + + it("should normalize missing Manual Pass evidence without inventing content", () => { + // Given + const input = { + data: { + id: "triage-without-evidence", + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.OPEN, + manual_pass_active: false, + manual_pass_created_at: "2026-06-03T10:00:00Z", + manual_pass_expires_at: "2026-06-17T10:00:00Z", + }, + }, + }; + + // When + const detail = adaptFindingTriageDetailResponse(input); + + // Then + expect(detail.manualPassEvidence).toBeNull(); + expect(detail.manualPassDeactivatedAt).toBeNull(); + }); + it("should normalize provisional detail payloads into modal DTOs", () => { // Given const input = findingTriageDetailResponse; @@ -395,6 +507,10 @@ describe("adaptFindingTriageDetailResponse", () => { findingId: "finding-1", findingUid: "prowler-finding-uid-1", status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + rawFindingStatus: "MANUAL", + manualPassCreatedByName: "Alex Security", + manualPassCreatedAt: "2026-06-03T10:00:00Z", + manualPassExpiresAt: "2026-06-17T10:00:00Z", label: "Risk Accepted", hasVisibleNote: true, canEdit: true, diff --git a/ui/actions/findings/findings-triage.adapter.ts b/ui/actions/findings/findings-triage.adapter.ts index 2ddfec94e9..a810a83571 100644 --- a/ui/actions/findings/findings-triage.adapter.ts +++ b/ui/actions/findings/findings-triage.adapter.ts @@ -1,13 +1,16 @@ +import { FINDING_STATUS, type FindingStatus } from "@/types/components"; import { FINDING_TRIAGE_BILLING_HREF, FINDING_TRIAGE_NOTE_MAX_LENGTH, FINDING_TRIAGE_STATUS, FINDING_TRIAGE_STATUS_LABELS, + MANUAL_PASS_PROVENANCE, type FindingTriageDetail, type FindingTriageDisabledReason, type FindingTriageLoadedNote, type FindingTriageStatus, type FindingTriageSummary, + type FindingTriageUpdateResult, } from "@/types/findings-triage"; // API/backend triage implementation is external to this UI slice. Keep final @@ -25,6 +28,7 @@ interface FindingTriageAttributes { uid?: string; triage_id?: string; triage_notes_count?: number; + notes_count?: number; triage_status?: unknown; triage_has_note?: boolean; status?: unknown; @@ -34,6 +38,14 @@ interface FindingTriageAttributes { note?: string; has_note?: boolean; note_id?: string; + raw_finding_status?: unknown; + raw_status?: unknown; + manual_pass_active?: unknown; + manual_pass_evidence?: unknown; + manual_pass_created_by_name?: unknown; + manual_pass_created_at?: unknown; + manual_pass_expires_at?: unknown; + manual_pass_deactivated_at?: unknown; } interface JsonApiResource { @@ -59,10 +71,14 @@ const isFindingTriageStatus = (value: unknown): value is FindingTriageStatus => typeof value === "string" && Object.values(FINDING_TRIAGE_STATUS).includes(value as FindingTriageStatus); +const isRawFindingStatus = (value: unknown): value is FindingStatus => + typeof value === "string" && + Object.values(FINDING_STATUS).includes(value as FindingStatus); + const fallbackStatusFromFindingStatus = ( findingStatus: unknown, ): FindingTriageStatus => - findingStatus === "PASS" + findingStatus === FINDING_STATUS.PASS ? FINDING_TRIAGE_STATUS.RESOLVED : FINDING_TRIAGE_STATUS.OPEN; @@ -93,6 +109,13 @@ const createSummary = ( options: FindingTriageAdapterOptions, ): FindingTriageSummary => { const attributes = finding.attributes ?? {}; + const rawFindingStatus = isRawFindingStatus(attributes.raw_finding_status) + ? attributes.raw_finding_status + : isRawFindingStatus(attributes.raw_status) + ? attributes.raw_status + : isRawFindingStatus(attributes.status) + ? attributes.status + : null; const summary: FindingTriageSummary = { findingId: attributes.finding_id || finding.id || "", findingUid: attributes.uid || attributes.finding_uid || "", @@ -105,6 +128,13 @@ const createSummary = ( typeof attributes.muted === "boolean" ? attributes.muted : attributes.status === "MUTED", + rawFindingStatus, + manualPassProvenance: + attributes.status === FINDING_STATUS.PASS && + rawFindingStatus === FINDING_STATUS.MANUAL && + triageFields.status === FINDING_TRIAGE_STATUS.RESOLVED + ? MANUAL_PASS_PROVENANCE + : null, canEdit: options.canEdit ?? false, billingHref: options.billingHref ?? FINDING_TRIAGE_BILLING_HREF, }; @@ -192,19 +222,24 @@ export function adaptFindingTriageDetailResponse( : typeof attributes.note === "string" ? attributes.note : ""; + const notesCount = + attributes.triage_notes_count ?? + attributes.notes_count ?? + (noteBody.length > 0 ? 1 : 0); const summary = createSummary( { id: attributes.finding_id || data?.id || "", attributes: { finding_uid: attributes.finding_uid || "", triage_id: data?.id, - triage_notes_count: - attributes.triage_notes_count ?? (noteBody.length > 0 ? 1 : 0), + triage_notes_count: notesCount, + raw_finding_status: attributes.raw_finding_status, }, }, { status, - hasVisibleNote: attributes.has_note === true || noteBody.length > 0, + hasVisibleNote: + attributes.has_note === true || notesCount > 0 || noteBody.length > 0, }, options, ); @@ -214,5 +249,46 @@ export function adaptFindingTriageDetailResponse( noteId: attributes.note_id || null, noteBody, maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, + rawFindingStatus: isRawFindingStatus(attributes.raw_finding_status) + ? attributes.raw_finding_status + : null, + manualPassActive: + typeof attributes.manual_pass_active === "boolean" + ? attributes.manual_pass_active + : null, + manualPassEvidence: + typeof attributes.manual_pass_evidence === "string" + ? attributes.manual_pass_evidence + : null, + manualPassCreatedByName: + typeof attributes.manual_pass_created_by_name === "string" + ? attributes.manual_pass_created_by_name + : null, + manualPassCreatedAt: + typeof attributes.manual_pass_created_at === "string" + ? attributes.manual_pass_created_at + : null, + manualPassExpiresAt: + typeof attributes.manual_pass_expires_at === "string" + ? attributes.manual_pass_expires_at + : null, + manualPassDeactivatedAt: + typeof attributes.manual_pass_deactivated_at === "string" + ? attributes.manual_pass_deactivated_at + : null, + }; +} + +export function adaptFindingTriageUpdateResponse( + apiResponse: unknown, +): FindingTriageUpdateResult { + const data = + isRecord(apiResponse) && isJsonApiResource(apiResponse.data) + ? apiResponse.data + : undefined; + const expiresAt = data?.attributes?.manual_pass_expires_at; + + return { + manualPassExpiresAt: typeof expiresAt === "string" ? expiresAt : null, }; } diff --git a/ui/actions/findings/findings-triage.fixtures.ts b/ui/actions/findings/findings-triage.fixtures.ts index 879564ed8b..5dee77adcf 100644 --- a/ui/actions/findings/findings-triage.fixtures.ts +++ b/ui/actions/findings/findings-triage.fixtures.ts @@ -88,6 +88,10 @@ export const findingTriageDetailResponse = { finding_id: "finding-1", finding_uid: "prowler-finding-uid-1", status: "risk_accepted", + raw_finding_status: "MANUAL", + manual_pass_created_by_name: "Alex Security", + manual_pass_created_at: "2026-06-03T10:00:00Z", + manual_pass_expires_at: "2026-06-17T10:00:00Z", triage_notes_count: 1, has_note: true, note_id: "note-detail-1", diff --git a/ui/actions/findings/findings-triage.test.ts b/ui/actions/findings/findings-triage.test.ts index d494b64be5..ce0d0059b2 100644 --- a/ui/actions/findings/findings-triage.test.ts +++ b/ui/actions/findings/findings-triage.test.ts @@ -7,11 +7,17 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock, + revalidatePathMock, } = vi.hoisted(() => ({ createMuteRuleMock: vi.fn(), fetchMock: vi.fn(), getAuthHeadersMock: vi.fn(), handleApiResponseMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, })); vi.mock("@/actions/mute-rules", () => ({ @@ -38,6 +44,75 @@ describe("findings triage actions", () => { fetchMock.mockResolvedValue(new Response(null, { status: 200 })); }); + it("should load authoritative manual pass context through the finding UID route", async () => { + // Given + const { loadFindingTriageDetail } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + data: { + id: "triage-1", + type: "finding-triages", + attributes: { + finding_uid: "finding/stable/uid", + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + raw_finding_status: "MANUAL", + notes_count: 0, + }, + }, + }); + + // When + const result = await loadFindingTriageDetail({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: false, + isMuted: false, + canEdit: true, + billingHref: "https://prowler.com/pricing", + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage", + expect.any(Object), + ); + expect(result).toEqual( + expect.objectContaining({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + rawFindingStatus: "MANUAL", + }), + ); + }); + + it("should reject API errors when loading triage detail", async () => { + // Given + const { loadFindingTriageDetail } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + status: 403, + errors: [{ detail: "Finding triage access denied." }], + }); + + // When / Then + await expect( + loadFindingTriageDetail({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.OPEN, + label: "Open", + hasVisibleNote: false, + isMuted: false, + canEdit: true, + billingHref: "https://prowler.com/pricing", + }), + ).rejects.toThrow("Finding triage access denied."); + }); + it("should load notes through the persisted triage route when triageId exists", async () => { // Given const { loadLatestFindingTriageNote } = await importActions(); @@ -185,6 +260,127 @@ describe("findings triage actions", () => { }), }), ); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("should send manual pass status and fresh evidence in one triage request", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + data: { + id: "triage-1", + attributes: { + manual_pass_expires_at: "2026-10-28T12:00:00Z", + }, + }, + }); + + // When + const result = await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 1, + noteId: "note-1", + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence: "Fresh control-owner evidence.", + }); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + manualPassExpiresAt: "2026-10-28T12:00:00Z", + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.RESOLVED, + note: "Fresh control-owner evidence.", + }, + }, + }), + }), + ); + expect(revalidatePathMock).toHaveBeenCalledWith("/findings"); + }); + + it("should accept manual pass evidence at the maximum length", async () => { + // Given + const { updateFindingTriage } = await importActions(); + const manualPassEvidence = "a".repeat(500); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence, + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1", + expect.objectContaining({ + method: "PATCH", + body: expect.stringContaining(manualPassEvidence), + }), + ); + }); + + it("should reject manual pass evidence over the maximum length", async () => { + // Given + const { updateFindingTriage } = await importActions(); + + // When / Then + await expect( + updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence: "a".repeat(501), + }), + ).rejects.toThrow("Manual pass evidence cannot exceed 500 characters."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should surface JSON:API validation details when the manual pass patch fails", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + errors: [ + { + detail: "Manual pass evidence must describe the verification.", + source: { pointer: "/data/attributes/note" }, + }, + ], + }); + + // When / Then + await expect( + updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 1, + noteId: "note-1", + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence: "Fresh control-owner evidence.", + }), + ).rejects.toThrow("Manual pass evidence must describe the verification."); }); it("should update an existing note through its note id", async () => { diff --git a/ui/actions/findings/findings-triage.ts b/ui/actions/findings/findings-triage.ts index c08c7a1335..8720a08807 100644 --- a/ui/actions/findings/findings-triage.ts +++ b/ui/actions/findings/findings-triage.ts @@ -1,11 +1,20 @@ "use server"; -import { adaptLatestFindingTriageNote } from "@/actions/findings/findings-triage.adapter"; +import { revalidatePath } from "next/cache"; + +import { + adaptFindingTriageUpdateResponse, + adaptFindingTriageDetailResponse, + adaptLatestFindingTriageNote, +} from "@/actions/findings/findings-triage.adapter"; import { createMuteRule } from "@/actions/mute-rules"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { FINDING_TRIAGE_STATUS_LABELS, + FINDING_TRIAGE_NOTE_MAX_LENGTH, + FINDING_TRIAGE_STATUS, + type FindingTriageDetail, type FindingTriageLoadedNote, type FindingTriageSummary, isMutelistShortcutStatus, @@ -65,15 +74,28 @@ const throwIfApiError = (result: unknown) => { result && typeof result === "object" && ("error" in result || + ("errors" in result && Array.isArray(result.errors)) || ("status" in result && typeof result.status === "number" && result.status >= 400)) ) { - throw new Error( - "error" in result && typeof result.error === "string" + const jsonApiDetail = + "errors" in result && Array.isArray(result.errors) + ? result.errors.find((error): error is { detail: string } => + Boolean( + error && + typeof error === "object" && + "detail" in error && + typeof error.detail === "string", + ), + )?.detail + : undefined; + const message = + ("error" in result && typeof result.error === "string" ? result.error - : "Finding triage request failed.", - ); + : jsonApiDetail) || "Finding triage request failed."; + + throw new Error(message); } }; @@ -225,6 +247,28 @@ export async function loadLatestFindingTriageNote( return latestNote; } +export async function loadFindingTriageDetail( + triage: FindingTriageSummary, +): Promise { + const findingUid = triage.findingUid || (await resolveFindingUid(triage)); + const apiResponse = await getJsonApi( + `/findings/${encodePathSegment(findingUid)}/triage`, + ); + throwIfApiError(apiResponse); + const detail = adaptFindingTriageDetailResponse(apiResponse, { + canEdit: triage.canEdit, + disabledReason: triage.disabledReason, + billingHref: triage.billingHref, + }); + + return { + ...detail, + findingId: triage.findingId, + findingUid, + isMuted: triage.isMuted, + }; +} + export async function updateFindingTriage(input: UpdateFindingTriageInput) { let findingUid: string | undefined; let triagePath: `/${string}`; @@ -236,6 +280,29 @@ export async function updateFindingTriage(input: UpdateFindingTriageInput) { triagePath = `/findings/${encodePathSegment(findingUid)}/triage`; } + if (input.status === FINDING_TRIAGE_STATUS.RESOLVED) { + const evidence = input.manualPassEvidence?.trim(); + if (!evidence) { + throw new Error("Fresh evidence is required to pass a MANUAL finding."); + } + if (evidence.length > FINDING_TRIAGE_NOTE_MAX_LENGTH) { + throw new Error( + `Manual pass evidence cannot exceed ${FINDING_TRIAGE_NOTE_MAX_LENGTH} characters.`, + ); + } + + const result = await patchJsonApi( + triagePath, + buildFindingTriageBody({ + status: input.status, + note: evidence, + }), + ); + + revalidatePath("/findings"); + return adaptFindingTriageUpdateResponse(result); + } + if (input.note !== undefined && input.notesCount > 0 && input.noteId) { const notePath: `/${string}` = `${triagePath}/notes/${input.noteId}`; const noteResult = diff --git a/ui/actions/organizations/organizations.adapter.ts b/ui/actions/organizations/organizations.adapter.ts index 7753d66f54..f63fcadf0e 100644 --- a/ui/actions/organizations/organizations.adapter.ts +++ b/ui/actions/organizations/organizations.adapter.ts @@ -5,6 +5,8 @@ import { ApplyDiscoveryPayload, AwsDiscoveryResult, AwsOrgHierarchy, + AzureDiscoveryResult, + AzureOrgHierarchy, GcpDiscoveryResult, GcpOrgHierarchy, NODE_KIND, @@ -84,6 +86,43 @@ export function mapGcpDiscovery(result: GcpDiscoveryResult): GcpOrgHierarchy { }; } +/** + * Ingestion mapper: Azure discovery wire result → normalized hierarchy model. + * + * A management group's identity is its canonical resource ID + * (`/providers/Microsoft.Management/managementGroups/{name}`), which is exactly + * what its children carry as `parent_id`, so nesting matches on that ref. The + * selected root group is collapsed away — it is absent from the node set, so + * groups and subscriptions parented by it rebuild as top-level. + */ +export function mapAzureDiscovery( + result: AzureDiscoveryResult, +): AzureOrgHierarchy { + return { + orgType: ORGANIZATION_TYPE.AZURE, + organization: { + // Tenant id: what the user typed and what the organization stores as + // `external_id`. + uid: result.root_management_group.tenant_id, + name: + result.root_management_group.display_name || + result.root_management_group.name, + }, + nodes: result.management_groups.map((group) => ({ + id: group.id, + kind: NODE_KIND.MANAGEMENT_GROUP, + name: group.display_name || group.name, + parentId: group.parent_id, + })), + candidates: result.subscriptions.map((subscription) => ({ + uid: subscription.subscription_id, + label: subscription.display_name || subscription.subscription_id, + parentId: subscription.parent_id, + registration: subscription.registration, + })), + }; +} + /** * Transforms the normalized hierarchy into hierarchical TreeDataItem[] for * TreeView. Container nodes (OUs / folders) nest candidates (accounts / @@ -338,6 +377,14 @@ export function buildApplyPayload( selectedCandidateIds, ).map((id) => ({ id })), }; + case ORGANIZATION_TYPE.AZURE: + return { + orgType: ORGANIZATION_TYPE.AZURE, + subscriptions: selectedCandidateIds.map((id) => ({ + subscription_id: id, + ...aliasOf(id), + })), + }; case ORGANIZATION_TYPE.GCP: return { orgType: ORGANIZATION_TYPE.GCP, diff --git a/ui/actions/organizations/organizations.test.ts b/ui/actions/organizations/organizations.test.ts index da77422ed1..013e906eac 100644 --- a/ui/actions/organizations/organizations.test.ts +++ b/ui/actions/organizations/organizations.test.ts @@ -51,6 +51,7 @@ describe("organizations actions", () => { it("rejects invalid organization secret identifiers", async () => { // When const result = await updateOrganizationSecret("../secret-id", { + orgType: ORGANIZATION_TYPE.AWS, secretType: ORG_SECRET_TYPE.ROLE, secret: { role_arn: "arn:aws:iam::123456789012:role/ProwlerOrgRole", @@ -85,13 +86,13 @@ describe("organizations actions", () => { }); it("rejects an organization type with no onboarding flow instead of coercing it", async () => { - // Given a form asking for a type this build cannot onboard. `azure` is a - // real OrganizationType — display supports it, onboarding does not — so it - // is the exact boundary a blind cast would let through. + // Given a type this build cannot onboard. `oraclecloud` is real — display + // supports it, onboarding does not — the exact boundary a blind cast lets + // through. const formData = new FormData(); - formData.set("name", "Contoso"); - formData.set("externalId", "o-abc123def4"); - formData.set("orgType", ORGANIZATION_TYPE.AZURE); + formData.set("name", "Tenancy"); + formData.set("externalId", "ocid1.tenancy.oc1..aaaa1111"); + formData.set("orgType", "oraclecloud"); // When const result = await createOrganization(formData); diff --git a/ui/actions/organizations/organizations.ts b/ui/actions/organizations/organizations.ts index ec177d54bd..69509f0376 100644 --- a/ui/actions/organizations/organizations.ts +++ b/ui/actions/organizations/organizations.ts @@ -489,11 +489,34 @@ export const getDiscovery = async ( } }; +/** + * JSON:API attributes for an apply request. The `default` arm is the + * exhaustiveness guard — `noImplicitReturns` is off. + */ +function buildApplyAttributes(payload: ApplyDiscoveryPayload) { + switch (payload.orgType) { + case ORGANIZATION_TYPE.AWS: + return { + accounts: payload.accounts, + organizational_units: payload.organizationalUnits, + }; + case ORGANIZATION_TYPE.AZURE: + return { subscriptions: payload.subscriptions }; + case ORGANIZATION_TYPE.GCP: + return { projects: payload.projects }; + default: { + const exhaustivePayload: never = payload; + return exhaustivePayload; + } + } +} + /** * Applies discovery results — creates providers, links to org/nodes, * auto-generates secrets. The payload is discriminated by organization type: * AWS sends `accounts` + client-side-derived `organizational_units`; GCP sends - * `projects` only (folder ancestors are derived server-side). + * `projects` and Azure `subscriptions` only (folder / Management Group ancestors + * are derived server-side). * POST /api/v1/organizations/{orgId}/discoveries/{discoveryId}/apply */ export const applyDiscovery = async ( @@ -525,13 +548,7 @@ export const applyDiscovery = async ( // whole request. The created providers' uids are read afterwards instead, with // `getProviderUidsByIds`. - const attributes = - payload.orgType === ORGANIZATION_TYPE.AWS - ? { - accounts: payload.accounts, - organizational_units: payload.organizationalUnits, - } - : { projects: payload.projects }; + const attributes = buildApplyAttributes(payload); try { const response = await fetch(url.toString(), { diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts index 3546649604..3b253cce58 100644 --- a/ui/actions/roles/roles.test.ts +++ b/ui/actions/roles/roles.test.ts @@ -49,6 +49,7 @@ const makeRoleFormData = () => { formData.set("manage_integrations", "false"); formData.set("manage_scans", "false"); formData.set("manage_alerts", "true"); + formData.set("manage_lighthouse_ai_configuration", "true"); formData.set("unlimited_visibility", "false"); return formData; }; @@ -106,4 +107,56 @@ describe("role actions", () => { // Then expect(lastRequestBody().data.attributes.manage_alerts).toBe(true); }); + + it("includes manage_lighthouse_ai_configuration when creating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect( + lastRequestBody().data.attributes.manage_lighthouse_ai_configuration, + ).toBe(true); + }); + + it("omits manage_lighthouse_ai_configuration when creating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect(lastRequestBody().data.attributes).not.toHaveProperty( + "manage_lighthouse_ai_configuration", + ); + }); + + it("includes manage_lighthouse_ai_configuration when updating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await updateRole(makeRoleFormData(), "role-1"); + + // Then + expect( + lastRequestBody().data.attributes.manage_lighthouse_ai_configuration, + ).toBe(true); + }); + + it("omits manage_lighthouse_ai_configuration when updating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await updateRole(makeRoleFormData(), "role-1"); + + // Then + expect(lastRequestBody().data.attributes).not.toHaveProperty( + "manage_lighthouse_ai_configuration", + ); + }); }); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index cfe3837414..972d6d12e8 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -114,6 +114,8 @@ export const addRole = async (formData: FormData) => { formData.get("manage_billing") === "true"; payload.data.attributes.manage_alerts = formData.get("manage_alerts") === "true"; + payload.data.attributes.manage_lighthouse_ai_configuration = + formData.get("manage_lighthouse_ai_configuration") === "true"; } // Add provider groups relationships only if there are items @@ -171,6 +173,8 @@ export const updateRole = async (formData: FormData, roleId: string) => { formData.get("manage_billing") === "true"; payload.data.attributes.manage_alerts = formData.get("manage_alerts") === "true"; + payload.data.attributes.manage_lighthouse_ai_configuration = + formData.get("manage_lighthouse_ai_configuration") === "true"; } // Add provider groups relationships only if there are items diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts index 03b990c1d5..3dcf3c5e64 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts @@ -9,8 +9,8 @@ describe("findings view overview SSR", () => { const filePath = path.join(currentDir, "findings-view.ssr.tsx"); const source = readFileSync(filePath, "utf8"); - it("uses the non-legacy latest findings columns", () => { - expect(source).toContain("ColumnLatestFindings"); + it("uses the triage-aware latest findings table", () => { + expect(source).toContain("LatestFindingsTable"); expect(source).not.toContain("ColumnNewFindingsToDate"); }); }); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index b408ed94ff..26e4418eb2 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -2,9 +2,8 @@ import { getLatestFindings } from "@/actions/findings/findings"; import { LinkToFindings } from "@/components/overview"; -import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; +import { LatestFindingsTable } from "@/components/overview/new-findings-table/table"; import { CardTitle } from "@/components/shadcn"; -import { DataTable } from "@/components/shadcn/table"; import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib"; import { createDict } from "@/lib/utils"; import { FindingProps, SearchParamsProps } from "@/types"; @@ -61,9 +60,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { return (
    - diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx index 6f802e9d75..db17ef7c6e 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -22,8 +22,13 @@ import { useState, } from "react"; +import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; -import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; +import type { + AttackPathGraphData, + AttackPathOutcome, + GraphNode, +} from "@/types/attack-paths"; import { computeFilteredSubgraph, @@ -34,10 +39,16 @@ import { isProwlerFindingNode, resolveHiddenFindingIds, } from "../../_lib"; -import { layoutWithDagre } from "../../_lib/layout"; +import { + type AttackPathView, + buildAttackPathView, +} from "../../_lib/group-graph"; +import { layoutWithDagre, NODE_TYPE } from "../../_lib/layout"; import { FindingNode } from "./nodes/finding-node"; +import { GroupNode } from "./nodes/group-node"; import { InternetNode } from "./nodes/internet-node"; +import { OutcomeNode } from "./nodes/outcome-node"; import { ResourceNode } from "./nodes/resource-node"; // --- Types --- @@ -57,7 +68,15 @@ interface AttackPathGraphProps { isFilteredView?: boolean; initialNodeId?: string; expandedResources?: Set; + // Which resource-class groups are expanded to their members. + expandedClasses?: Set; + // Terminal outcome for the current query; injected as the graph's end node. + outcome?: AttackPathOutcome | null; + // Pre-built attack-path view shared with the PNG export so both render the + // same grouped/outcome graph. Falls back to an internal build when omitted. + view?: AttackPathView | null; onNodeClick?: (node: GraphNode) => void; + onNodeDoubleClick?: (node: GraphNode) => void; onInitialFilter?: (filteredData: AttackPathGraphData) => void; ref?: Ref; className?: string; @@ -68,9 +87,11 @@ const EMPTY_EXPANDED: ReadonlySet = new Set(); // --- Node type registry (stable reference) --- const NODE_TYPES = { - finding: FindingNode, - internet: InternetNode, - resource: ResourceNode, + [NODE_TYPE.FINDING]: FindingNode, + [NODE_TYPE.INTERNET]: InternetNode, + [NODE_TYPE.RESOURCE]: ResourceNode, + [NODE_TYPE.GROUP]: GroupNode, + [NODE_TYPE.OUTCOME]: OutcomeNode, } as const; // --- CSS for animated dashed edges, selected node pulse, and edge highlight --- @@ -205,7 +226,11 @@ const GraphCanvas = ({ isFilteredView = false, initialNodeId, expandedResources, + expandedClasses, + outcome, + view, onNodeClick, + onNodeDoubleClick, onInitialFilter, ref, }: GraphCanvasProps) => { @@ -229,6 +254,23 @@ const GraphCanvas = ({ // survives the data-swaps that happen on filtered-view enter/exit. const expanded = expandedResources ?? EMPTY_EXPANDED; + // Re-frame when a class group is expanded/collapsed so the new member set + // fits smoothly instead of overflowing the viewport. Newly revealed members + // are measured asynchronously, so poll until they have real dimensions rather + // than fitting on a single frame where measured.width can still be 0. + useEffect(() => { + return scheduleMeasuredFit( + () => { + const visibleNodes = getNodes().filter((n) => !n.hidden); + return ( + visibleNodes.length > 0 && + visibleNodes.every((n) => (n.measured?.width ?? 0) > 0) + ); + }, + () => fitView(AUTO_FIT_OPTIONS), + ); + }, [expandedClasses, fitView, getNodes]); + // --- initialNodeId: synchronous filtered-view derivation on first render --- // Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph let effectiveData = data; @@ -380,8 +422,25 @@ const GraphCanvas = ({ ); }, [expanded, fitView, getNodes]); - const nodes = effectiveData.nodes ?? []; - const edges = effectiveData.edges ?? []; + // Cloud reshapes into the attack-path view: drop the account hub, collapse + // each resource class into one expandable node, and inject the outcome node. + // OSS keeps the original flat graph, so the grouped/outcome view is Cloud-only. + // Findings pass through either way; the finding-hide logic below reveals them + // per-resource, preserving existing behaviour. + // + // The parent builds and shares this view with the PNG export so both stay in + // sync; only fall back to an internal build when it is not provided. + const resolvedView = + view ?? + (isCloud() + ? buildAttackPathView({ + data: effectiveData, + expandedClasses: expandedClasses ?? EMPTY_EXPANDED, + outcome, + }) + : { nodes: effectiveData.nodes ?? [], edges: effectiveData.edges ?? [] }); + const nodes = resolvedView.nodes; + const edges = resolvedView.edges; // Pre-compute which resources have findings connected (O(n+e)) const findingNodeIds = new Set(); @@ -510,6 +569,11 @@ const GraphCanvas = ({ onNodeClick?.(graphNode); }; + const handleNodeDoubleClick = (_event: MouseEvent, node: Node) => { + const graphNode = (node.data as { graphNode: GraphNode }).graphNode; + onNodeDoubleClick?.(graphNode); + }; + // Path highlight on hover const handleNodeMouseEnter = (_event: MouseEvent, node: Node) => { setHoveredNodeId(node.id); @@ -526,6 +590,7 @@ const GraphCanvas = ({ edges={enrichedEdges} nodeTypes={NODE_TYPES} onNodeClick={handleNodeClick} + onNodeDoubleClick={handleNodeDoubleClick} onNodeMouseEnter={handleNodeMouseEnter} onNodeMouseLeave={handleNodeMouseLeave} fitView @@ -577,7 +642,11 @@ export const AttackPathGraph = ({ isFilteredView, initialNodeId, expandedResources, + expandedClasses, + outcome, + view, onNodeClick, + onNodeDoubleClick, onInitialFilter, ref, className, @@ -601,7 +670,11 @@ export const AttackPathGraph = ({ isFilteredView={isFilteredView} initialNodeId={initialNodeId} expandedResources={expandedResources} + expandedClasses={expandedClasses} + outcome={outcome} + view={view} onNodeClick={onNodeClick} + onNodeDoubleClick={onNodeDoubleClick} onInitialFilter={onInitialFilter} /> diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx index 13e254c4f4..0050c60128 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx @@ -19,9 +19,6 @@ describe("GraphControls", () => { }); expect(exportButton).toBeDisabled(); - expect( - screen.queryByRole("button", { name: /^export graph$/i }), - ).not.toBeInTheDocument(); }); it("enables the export button and invokes the callback when onExport is provided", async () => { @@ -34,10 +31,33 @@ describe("GraphControls", () => { name: /^export graph$/i, }); - expect(exportButton).toBeEnabled(); - await user.click(exportButton); expect(onExport).toHaveBeenCalledTimes(1); }); + + it("shows collapse all only when available and invokes its handler", async () => { + // Given + const user = userEvent.setup(); + const onCollapse = vi.fn(); + const { rerender } = render( + , + ); + + // Then + expect( + screen.queryByRole("button", { name: /collapse all groups/i }), + ).not.toBeInTheDocument(); + + // When + rerender( + , + ); + await user.click( + screen.getByRole("button", { name: /collapse all groups/i }), + ); + + // Then + expect(onCollapse).toHaveBeenCalledTimes(1); + }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx index ebb9704f7c..49c0622c32 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx @@ -1,6 +1,12 @@ "use client"; -import { Download, Minimize2, ZoomIn, ZoomOut } from "lucide-react"; +import { + ChevronsDownUp, + Download, + Minimize2, + ZoomIn, + ZoomOut, +} from "lucide-react"; import { Button } from "@/components/shadcn"; import { @@ -10,11 +16,21 @@ import { TooltipTrigger, } from "@/components/shadcn/tooltip"; +// Collapse-all is all-or-nothing: the enabled flag and its handler always +// travel together, so a button can never be enabled without something to do. +interface GraphCollapseAll { + can: boolean; + onCollapse: () => void; +} + interface GraphControlsProps { onZoomIn: () => void; onZoomOut: () => void; onFitToScreen: () => void; onExport?: () => void; + // Collapse every expanded resource-class group. Omitted where unsupported; + // the button hides itself while nothing is expanded. + collapseAll?: GraphCollapseAll; } /** @@ -26,18 +42,34 @@ export const GraphControls = ({ onZoomOut, onFitToScreen, onExport, + collapseAll, }: GraphControlsProps) => { return (
    + {collapseAll?.can && ( + + + + + Collapse all groups + + )} +
    @@ -690,9 +771,13 @@ export default function AttackPathsPage() { ref={graphRef} data={graphState.data} onNodeClick={handleNodeClick} + onNodeDoubleClick={handleNodeDoubleClick} selectedNodeId={graphState.selectedNodeId} isFilteredView={graphState.isFilteredView} expandedResources={graphState.expandedResources} + expandedClasses={graphState.expandedClasses} + outcome={executedOutcome} + view={attackPathView} />
    diff --git a/ui/app/(prowler)/compliance/_lib/cross-provider-frameworks.ts b/ui/app/(prowler)/compliance/_lib/cross-provider-frameworks.ts index 93a6e466eb..86643aaa86 100644 --- a/ui/app/(prowler)/compliance/_lib/cross-provider-frameworks.ts +++ b/ui/app/(prowler)/compliance/_lib/cross-provider-frameworks.ts @@ -60,6 +60,21 @@ export const CROSS_PROVIDER_FRAMEWORKS: CrossProviderFrameworkEntry[] = [ "Digital Operational Resilience Act (EU 2022/2554) — the EU framework for the digital operational resilience of the financial sector.", compatibleProviders: ["aws", "azure", "gcp", "alibabacloud", "cloudflare"], }, + { + complianceId: "cmmc_2.0", + title: "CMMC", + version: "2.0", + description: + "Cybersecurity Maturity Model Certification (CMMC) 2.0 (32 CFR Part 170) — the U.S. Department of Defense program verifying that defense contractors protect FCI and CUI across three levels.", + compatibleProviders: [ + "aws", + "azure", + "gcp", + "m365", + "alibabacloud", + "oraclecloud", + ], + }, ]; /** Resolves only canonical catalog links. Missing, unknown, or mismatched diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts index 1f9ac4d48d..31060984e9 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts @@ -288,6 +288,20 @@ describe("lighthouse-v2.adapter", () => { }); }); + it("should reject an unknown skill id instead of downgrading it", () => { + // Given + const invalidInput = { + displayText: "Removed skill", + skillId: "removed-skill", + provider: "openai", + } as unknown as Parameters[0]; + + // When / Then + expect(() => buildLighthouseV2MessagePayload(invalidInput)).toThrow( + "Unknown Lighthouse skill: removed-skill.", + ); + }); + it("should build per-provider update payloads with default_model and business_context", () => { // When const payload = buildLighthouseV2ConfigurationUpdatePayload("config-1", { diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts index 5c7a7f00ed..f0f30d88d7 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts @@ -9,17 +9,15 @@ import { type LighthouseV2Part, type LighthouseV2PartType, type LighthouseV2ProviderType, + type LighthouseV2SendMessageInput, type LighthouseV2Session, type LighthouseV2SupportedModel, type LighthouseV2SupportedProvider, type LighthouseV2Task, } from "@/app/(prowler)/lighthouse/_types"; -import { - buildAgentText, - toApiLighthouseContext, -} from "@/lib/lighthouse/context/transport"; +import { buildLighthouseMessageContent } from "@/lib/lighthouse/message-content"; +import { getSkillById } from "@/lib/lighthouse/skills/registry"; import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi"; -import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; import type { TaskAttributes as ApiTaskAttributes, TaskState, @@ -255,22 +253,18 @@ export function buildLighthouseV2SessionUpdatePayload( }; } -export function buildLighthouseV2MessagePayload(input: { - displayText: string; - context?: LighthouseContextEnvelope; - provider: LighthouseV2ProviderType; - model?: string | null; -}) { - const apiContext = input.context - ? toApiLighthouseContext(input.context) - : undefined; - const content = apiContext - ? { - text: buildAgentText(input.displayText, apiContext), - display_text: input.displayText, - ui_context: apiContext, - } - : { text: input.displayText }; +export function buildLighthouseV2MessagePayload( + input: Omit, +) { + const skill = input.skillId ? getSkillById(input.skillId) : undefined; + if (input.skillId && !skill) { + throw new Error(`Unknown Lighthouse skill: ${input.skillId}.`); + } + const content = buildLighthouseMessageContent( + input.displayText, + input.context, + skill, + ); return { data: { diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts index bab81d7157..2a7bea074b 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts @@ -27,6 +27,7 @@ vi.mock("@/lib/helper", () => ({ import { createLighthouseV2Session, getLighthouseV2SupportedModels, + sendLighthouseV2Message, updateLighthouseV2Configuration, updateLighthouseV2Session, } from "./lighthouse-v2"; @@ -156,4 +157,28 @@ describe("Lighthouse v2 session write actions", () => { }), ); }); + + it("rejects an unknown skill id before sending the message", async () => { + // Given + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const invalidInput = { + sessionId: "session-1", + displayText: "Removed skill", + skillId: "removed-skill", + provider: "openai", + model: "gpt-5.1", + } as unknown as Parameters[0]; + + // When + const result = await sendLighthouseV2Message(invalidInput); + + // Then + expect(result).toEqual({ + error: "Unknown Lighthouse skill: removed-skill.", + status: 400, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts index c78233b35f..e2248db15c 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts @@ -14,6 +14,7 @@ import type { LighthouseV2SupportedProvider, } from "@/app/(prowler)/lighthouse/_types"; import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; +import { getSkillById } from "@/lib/lighthouse/skills/registry"; import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; import type { JsonApiDocument } from "@/types/jsonapi"; @@ -221,6 +222,22 @@ export async function getLighthouseV2Messages( export async function sendLighthouseV2Message( input: LighthouseV2SendMessageInput, ): Promise> { + if (input.skillId) { + const skill = getSkillById(input.skillId); + if (!skill) { + return { + error: `Unknown Lighthouse skill: ${input.skillId}.`, + status: 400, + }; + } + if (!skill.enabled) { + return { + error: `Lighthouse skill not available yet: ${input.skillId}.`, + status: 400, + }; + } + } + try { const response = await fetch( buildApiUrl( diff --git a/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx b/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx index 474fed577c..3b0049878f 100644 --- a/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx @@ -1,13 +1,8 @@ "use client"; import { useControllableState } from "@radix-ui/react-use-controllable-state"; -import { - BrainIcon, - ChevronDownIcon, - DotIcon, - type LucideIcon, -} from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; +import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react"; +import type { ComponentProps, ComponentType, ReactNode } from "react"; import { createContext, useContext } from "react"; import { Badge } from "@/components/shadcn/badge/badge"; @@ -117,7 +112,8 @@ export type ChainOfThoughtStatus = (typeof CHAIN_OF_THOUGHT_STATUS)[keyof typeof CHAIN_OF_THOUGHT_STATUS]; export type ChainOfThoughtStepProps = ComponentProps<"div"> & { - icon?: LucideIcon; + // Any className-driven icon fits (lucide icons, the shadcn Spinner, …). + icon?: ComponentType<{ className?: string }>; label: ReactNode; description?: ReactNode; status?: ChainOfThoughtStatus; diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx index d5b985d13c..ba7fd098a6 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx @@ -21,6 +21,9 @@ import type { LighthouseV2SupportedModel, LighthouseV2SupportedProvider, } from "@/app/(prowler)/lighthouse/_types"; +import { buildLighthouseMessageContent } from "@/lib/lighthouse/message-content"; +import { getSkillById } from "@/lib/lighthouse/skills/registry"; +import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills"; import { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page"; @@ -542,6 +545,41 @@ describe("LighthouseV2ChatPage", () => { ); }); + it("hands the suggested follow-up skill off to a fresh session", async () => { + // Given: a persisted triage run in the currently open session + const user = userEvent.setup(); + const triage = getSkillById(LIGHTHOUSE_SKILL_ID.TRIAGE_DECISION); + if (!triage) throw new Error("triage skill missing from the catalog"); + const launch = message("message-launch", "user", "Triage Decision"); + launch.parts[0].content = buildLighthouseMessageContent( + "Triage Decision", + undefined, + triage, + ); + renderPage({ + initialSessionId: "session-old", + initialMessages: [ + launch, + message("message-answer", "assistant", "Verdict: real risk"), + ], + }); + + // When: the receipt's suggested next skill is launched + await user.click( + screen.getByRole("button", { name: /Next: Contextual Fix/ }), + ); + + // Then: the catalog requires the fix to run in a separate session, so a + // new one is created and the triage conversation leaves the screen. + await waitFor(() => expect(sendMessageMock).toHaveBeenCalled()); + expect(createSessionMock).toHaveBeenCalled(); + expect(sendMessageMock.mock.calls[0][0]).toMatchObject({ + sessionId: "session-1", + skillId: LIGHTHOUSE_SKILL_ID.CONTEXTUAL_FIX, + }); + expect(screen.queryByText("Verdict: real risk")).not.toBeInTheDocument(); + }); + it("renders streamed deltas and reloads persisted messages on message.end", async () => { // Given const user = userEvent.setup(); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx index 49ad5462fe..87246de043 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx @@ -7,8 +7,12 @@ import { ConversationContent, ConversationScrollButton, } from "@/app/(prowler)/lighthouse/_components/ai-elements/conversation"; -import { selectLighthouseChatCanSend } from "@/app/(prowler)/lighthouse/_lib/chat-store"; +import { + selectLighthouseChatActiveSkill, + selectLighthouseChatCanSend, +} from "@/app/(prowler)/lighthouse/_lib/chat-store"; import { LIGHTHOUSE_V2_STREAM_STATUS } from "@/app/(prowler)/lighthouse/_lib/event-reducer"; +import { getSkillRunFromLaunch } from "@/app/(prowler)/lighthouse/_lib/messages"; import { buildLighthouseV2ModelSelectionValue, type LighthouseV2ModelSelection, @@ -36,6 +40,8 @@ import { ChatComposerPanel } from "./composer"; import { ChatEmptyState } from "./empty-state"; import { useLighthouseChatStore } from "./lighthouse-chat-store-provider"; import { MessageBubble } from "./message-bubble"; +import { SkillComposerPill } from "./skill-composer-pill"; +import { SkillRunProgress } from "./skill-run-progress"; import { StreamingAssistantMessage } from "./streaming-message"; export const LIGHTHOUSE_CHAT_SURFACE = { @@ -72,6 +78,7 @@ export function LighthouseV2ChatView({ dismissFeedback, selectModel, submitMessage, + resetToNewChat, retryLastMessage, } = state; const { modelsByProvider, supportedProviders } = config; @@ -113,6 +120,7 @@ export function LighthouseV2ChatView({ : ""; const canSend = selectLighthouseChatCanSend(state); + const activeSkill = selectLighthouseChatActiveSkill(state); const supportsAutomaticContext = surface === LIGHTHOUSE_CHAT_SURFACE.PANEL; const messageContext = supportsAutomaticContext ? currentContext.context @@ -142,9 +150,15 @@ export function LighthouseV2ChatView({ lastSubmission !== null, onRetry: () => void retryLastMessage(), onDismissFeedback: dismissFeedback, - contextControl: supportsAutomaticContext ? ( - - ) : undefined, + contextControl: + supportsAutomaticContext || activeSkill ? ( + <> + {activeSkill && } + {supportsAutomaticContext && ( + + )} + + ) : undefined, canSend, input, isStreaming: Boolean(streamState.activeTaskId), @@ -185,12 +199,36 @@ export function LighthouseV2ChatView({ className="mx-auto w-full max-w-4xl gap-5 px-4 pt-8 pb-20 md:px-8" scrollClassName="minimal-scrollbar overflow-x-hidden overflow-y-auto" > - {messages.map((message) => ( - - ))} - {hasLiveAssistantActivity && ( - - )} + {messages.map((message, index) => { + const skillRun = getSkillRunFromLaunch( + message, + messages[index - 1], + ); + return ( + { + // The DyR prompts hand follow-up skills off to a separate + // session; only the original launch context (it carries + // the finding) travels along. + resetToNewChat(); + void submitMessage(skill.name, skillRun?.context, skill); + }} + /> + ); + })} + {hasLiveAssistantActivity && + (activeSkill ? ( + + ) : ( + + ))} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx index ea9f82c488..83f67bc97e 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { type ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; @@ -78,6 +79,189 @@ describe("MessageBubble", () => { expect(screen.queryByText(/PROWLER_UI_CONTEXT_V1/)).not.toBeInTheDocument(); }); + it("should render a skill launch as a card instead of the raw prompt", () => { + // Given + const skillMessage: LighthouseV2Message = { + id: "message-user-skill", + role: LIGHTHOUSE_V2_MESSAGE_ROLE.USER, + model: null, + tokenUsage: null, + insertedAt: "2026-06-25T10:00:00Z", + parts: [ + { + id: "part-user-skill", + type: LIGHTHOUSE_V2_PART_TYPE.TEXT, + content: { + text: "[PROWLER_UI_SKILL_V1]\ninstructions\n[/PROWLER_UI_SKILL_V1]\n\nTriage Decision", + display_text: "Triage Decision", + ui_skill: { + skill_id: "triage-decision", + name: "Triage Decision", + version: 1, + }, + ui_context: { + schema_version: 1, + transport: "inline", + items: [ + { + kind: "finding", + id: "finding-1", + source: "focused", + scope_key: "findings:/findings", + label: + "Inline IAM policy does not allow '*:*' administrative privileges", + finding_id: "finding-1", + }, + ], + }, + }, + toolCallOutcome: null, + insertedAt: "2026-06-25T10:00:00Z", + updatedAt: "2026-06-25T10:00:00Z", + }, + ], + }; + + // When + render(); + + // Then + expect(screen.getByText("Skill")).toBeInTheDocument(); + expect(screen.getByText("Triage Decision")).toBeInTheDocument(); + expect( + screen.getByText( + "Inline IAM policy does not allow '*:*' administrative privileges", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/PROWLER_UI_SKILL_V1/)).not.toBeInTheDocument(); + }); + + it("should render a skill response with receipt and follow-up actions", async () => { + // Given + const user = userEvent.setup(); + const assistantMessage: LighthouseV2Message = { + id: "message-assistant-skill", + role: LIGHTHOUSE_V2_MESSAGE_ROLE.ASSISTANT, + model: null, + tokenUsage: null, + insertedAt: "2026-06-25T10:00:42Z", + parts: [ + { + id: "part-tool-1", + type: LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL, + content: { + tool_call_id: "tool-1", + tool_name: "get_finding", + arguments: {}, + result: "ok", + }, + toolCallOutcome: "success", + insertedAt: "2026-06-25T10:00:10Z", + updatedAt: "2026-06-25T10:00:12Z", + }, + { + id: "part-answer", + type: LIGHTHOUSE_V2_PART_TYPE.TEXT, + content: { + text: "The finding is exploitable in practice.", + }, + toolCallOutcome: null, + insertedAt: "2026-06-25T10:00:40Z", + updatedAt: "2026-06-25T10:00:40Z", + }, + ], + }; + const onLaunchSkill = vi.fn(); + + // When + render( + , + ); + + // Then: receipt with tools and duration — no plan-derived step count + expect(screen.getByText(/1 tool · 42s/)).toBeInTheDocument(); + expect( + screen.getByText("The finding is exploitable in practice."), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create Jira ticket" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Mute finding" }), + ).toBeInTheDocument(); + + // And the suggested next skill launches through the callback + await user.click( + screen.getByRole("button", { name: /Next: Contextual Fix/ }), + ); + expect(onLaunchSkill).toHaveBeenCalledOnce(); + expect(onLaunchSkill.mock.calls[0][0]).toMatchObject({ + id: "contextual-fix", + }); + }); + + it("should render the receipt as a static line with the tool trace inline", () => { + // Given: a finished skill run whose narration interleaves with a tool call + const assistantMessage = buildAssistantMessage([ + textPart("part-narration", "Checking the failed policy."), + toolCallPart("part-tool-1", "get_finding"), + textPart("part-answer", "Done."), + ]); + + render( + , + ); + + // Then: the receipt is informational only — it owns no disclosure… + expect(screen.getByText(/1 tool/)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Ran/ }), + ).not.toBeInTheDocument(); + + // …and the tool call renders in message order between the narration and + // the answer, behind the body's own group. + expect(screen.getByText("Checking the failed policy.")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Used Get finding/ }), + ).toBeInTheDocument(); + expect(screen.getByText("Done.")).toBeInTheDocument(); + }); + it("should render persisted user context as a read-only historical badge", () => { // Given const userMessage: LighthouseV2Message = { diff --git a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx index 9a62cd4610..b82974b652 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx @@ -6,7 +6,9 @@ import { useState } from "react"; import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format"; import { getLighthouseContext, + getSkillRef, getTextContent, + type SkillRunInfo, } from "@/app/(prowler)/lighthouse/_lib/messages"; import { LIGHTHOUSE_V2_MESSAGE_ROLE, @@ -17,8 +19,11 @@ import { import { LighthouseContextBadge } from "@/components/lighthouse/context-chip"; import { Button } from "@/components/shadcn/button/button"; import { cn } from "@/lib/utils"; +import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; import { MessageMarkdown } from "./message-markdown"; +import { SkillActionsRow, SkillRunReceipt } from "./skill-completed"; +import { SkillMessageCard } from "./skill-message-card"; import { ToolCalls } from "./tool-call-part"; const ASSISTANT_PART_GROUP_TYPE = { @@ -35,8 +40,18 @@ interface AssistantPartGroup { parts: LighthouseV2Part[]; } -export function MessageBubble({ message }: { message: LighthouseV2Message }) { +export function MessageBubble({ + message, + skillRun, + onLaunchSkill, +}: { + message: LighthouseV2Message; + // Present when this assistant message answered a skill launch (design 1j). + skillRun?: SkillRunInfo; + onLaunchSkill?: (skill: LighthouseSkillDefinition) => void; +}) { const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER; + const isSkillResponse = !isUser && skillRun !== undefined; // Text-only join feeds the copy button; tool calls are rendered separately. const messageText = message.parts .filter((part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT) @@ -49,6 +64,13 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) { .map((part) => getLighthouseContext(part.content)) .find((context) => context !== undefined) : undefined; + // A user turn that launched a skill renders as a card, not as prompt text. + const messageSkill = isUser + ? message.parts + .filter((part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT) + .map((part) => getSkillRef(part.content)) + .find((skillRef) => skillRef !== undefined) + : undefined; return (
    {messageContext && } -
    - {/* User text stays plain to preserve HTML-like tags; assistant - renders parts in order so tool calls sit between text blocks. */} - {isUser ? ( -

    {messageText}

    - ) : ( - - )} -
    + {isSkillResponse && ( + + )} + {messageSkill ? ( + + ) : ( +
    + {/* User text stays plain to preserve HTML-like tags; assistant + renders parts in order so tool calls sit between the narration + text that announced them — skill responses included, with the + receipt above as the run summary. */} + {isUser ? ( +

    + {messageText} +

    + ) : ( + + )} +
    + )} + {isSkillResponse && ( + + )} { + describe("when streamed inline code is incomplete", () => { + it("should not render a code block inside a paragraph", async () => { + // Given + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const incompleteMarkdown = + "The AWS resource policy is **currently:** `public\nwhile checking"; + + // When + const { container } = render( + , + ); + await waitFor(() => + expect(container.querySelector("pre")).not.toBeNull(), + ); + + // Then + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe("when markdown contains a regular paragraph", () => { + it("should preserve paragraph semantics", async () => { + // Given + const markdown = "The **AWS** resource is private."; + + // When + const { container } = render(); + await waitFor(() => expect(container.querySelector("p")).not.toBeNull()); + + // Then + expect(container.querySelector("p")).toHaveTextContent( + "The AWS resource is private.", + ); + }); + }); + + describe("when markdown contains a standalone image", () => { + it("should not wrap the image block in a paragraph", async () => { + // Given + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const markdown = + "![AWS architecture](https://example.com/architecture.png)"; + + // When + const { container } = render(); + await waitFor(() => + expect(container.querySelector("img")).not.toBeNull(), + ); + + // Then + expect(consoleError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/message-markdown.tsx b/ui/app/(prowler)/lighthouse/_components/chat/message-markdown.tsx index 89755c155e..75f61a641d 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/message-markdown.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/message-markdown.tsx @@ -1,7 +1,37 @@ -import { defaultRehypePlugins, Streamdown } from "streamdown"; +import { + defaultRehypePlugins, + Streamdown, + type StreamdownProps, +} from "streamdown"; import { escapeAngleBracketPlaceholders } from "@/lib/markdown"; +interface MarkdownPositionPoint { + line: number; +} + +interface MarkdownPosition { + start: MarkdownPositionPoint; + end: MarkdownPositionPoint; +} + +interface MarkdownNode { + tagName?: string; + position?: MarkdownPosition; + children?: unknown[]; +} + +const STREAMDOWN_COMPONENTS = { + p: ({ node, children, ...props }) => { + if (isStandaloneImage(node)) return <>{children}; + + // Streamdown renders multiline code nodes as blocks, including unfinished + // inline code that its streaming parser still places inside a paragraph. + const Paragraph = containsMultilineCode(node) ? "div" : "p"; + return {children}; + }, +} satisfies NonNullable; + // Renders assistant message text as markdown (code blocks, tables, lists), // matching the Lighthouse v1 chat. `isStreaming` animates partial output. export function MessageMarkdown({ @@ -15,6 +45,7 @@ export function MessageMarkdown({
    ) @@ -30,3 +61,27 @@ export function MessageMarkdown({
    ); } + +function containsMultilineCode(node: unknown): boolean { + if (!isMarkdownNode(node)) return false; + + const isMultilineCode = + node.tagName === "code" && + node.position?.start.line !== node.position?.end.line; + + return ( + isMultilineCode || + (node.children?.some((child) => containsMultilineCode(child)) ?? false) + ); +} + +function isStandaloneImage(node: unknown): boolean { + if (!isMarkdownNode(node) || node.children?.length !== 1) return false; + + const [child] = node.children; + return isMarkdownNode(child) && child.tagName === "img"; +} + +function isMarkdownNode(value: unknown): value is MarkdownNode { + return typeof value === "object" && value !== null; +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/skill-completed.tsx b/ui/app/(prowler)/lighthouse/_components/chat/skill-completed.tsx new file mode 100644 index 0000000000..5e32d43f0e --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/skill-completed.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { ArrowRight, BrainIcon } from "lucide-react"; +import dynamic from "next/dynamic"; +import { useState } from "react"; + +import type { SkillRunInfo } from "@/app/(prowler)/lighthouse/_lib/messages"; +import { + LIGHTHOUSE_V2_PART_TYPE, + type LighthouseV2Part, +} from "@/app/(prowler)/lighthouse/_types"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { getNextSkill } from "@/lib/lighthouse/skills/registry"; +import { + JIRA_DISPATCH_TARGET, + JIRA_TARGET_SELECTION_KIND, +} from "@/types/integrations"; +import { LIGHTHOUSE_CONTEXT_KIND } from "@/types/lighthouse-context"; +import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; + +// Lazy-loaded: the Jira/Mute machinery (and its server-action imports) only +// loads when the user actually opens one of these from a finished skill run. +// `loading` gives each modal its own Suspense boundary — without it the lazy +// load suspends up to the chat panel's boundary and swaps the whole +// conversation for its skeleton fallback while the chunk downloads. +const SendToJiraModal = dynamic( + () => + import("@/components/findings/send-to-jira-modal").then( + (module) => module.SendToJiraModal, + ), + { loading: () => null }, +); +const MuteFindingsModal = dynamic( + () => + import("@/components/findings/mute-findings-modal").then( + (module) => module.MuteFindingsModal, + ), + { loading: () => null }, +); + +// One-line receipt of a finished run (design 1j): "Ran · tools · +// time". Static on purpose — the full trace (narration and tool calls, in +// order) lives in the message body, so the receipt never duplicates it. The +// summary reports only observed activity — never the catalog's step plan. +export function SkillRunReceipt({ + skillRun, + parts, + completedAt, +}: { + skillRun: SkillRunInfo; + parts: LighthouseV2Part[]; + completedAt: string; +}) { + const toolParts = parts.filter( + (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL, + ); + const summary = [ + `${toolParts.length} ${toolParts.length === 1 ? "tool" : "tools"}`, + formatRunDuration(skillRun.launchedAt, completedAt), + ] + .filter(Boolean) + .join(" · "); + + return ( +
    + + + Ran{" "} + + {skillRun.ref.name} + + {summary && ` · ${summary}`} + +
    + ); +} + +// Action row under the answer (design 1j): copy, dispatch to Jira, mute the +// finding, and the suggested follow-up skill. +export function SkillActionsRow({ + skillRun, + onLaunchSkill, +}: { + skillRun: SkillRunInfo; + onLaunchSkill?: (skill: LighthouseSkillDefinition) => void; +}) { + const [isJiraOpen, setIsJiraOpen] = useState(false); + const [isMuteOpen, setIsMuteOpen] = useState(false); + const finding = skillRun.context?.items.find( + (item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.FINDING, + ); + const findingId = + finding?.kind === LIGHTHOUSE_CONTEXT_KIND.FINDING + ? finding.findingId + : undefined; + const nextSkill = getNextSkill(skillRun.ref.skillId); + + return ( +
    + {findingId && ( + <> + + + {isJiraOpen && ( + + )} + {isMuteOpen && ( + + )} + + )} + {nextSkill && onLaunchSkill && ( + + + + )} +
    + ); +} + +function formatRunDuration( + launchedAt: string, + completedAt: string, +): string | null { + const started = new Date(launchedAt).getTime(); + const finished = new Date(completedAt).getTime(); + if (Number.isNaN(started) || Number.isNaN(finished) || finished < started) { + return null; + } + const totalSeconds = Math.round((finished - started) / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + return `${minutes}m ${totalSeconds % 60}s`; +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/skill-composer-pill.tsx b/ui/app/(prowler)/lighthouse/_components/chat/skill-composer-pill.tsx new file mode 100644 index 0000000000..075fba6eb7 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/skill-composer-pill.tsx @@ -0,0 +1,20 @@ +import { Badge } from "@/components/shadcn/badge/badge"; +import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; + +// Composer pill (design 1e): pinned next to the context chip while a skill run +// is live. Purely informative — there is no cancel/stop yet. +export function SkillComposerPill({ + skill, +}: { + skill: LighthouseSkillDefinition; +}) { + return ( + + + Skill · {skill.name} + + ); +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.tsx b/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.tsx new file mode 100644 index 0000000000..6852bda8cc --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.tsx @@ -0,0 +1,45 @@ +import { Sparkles } from "lucide-react"; + +import { getSkillById } from "@/lib/lighthouse/skills/registry"; +import { + LIGHTHOUSE_CONTEXT_KIND, + type LighthouseContextEnvelope, +} from "@/types/lighthouse-context"; +import type { LighthouseSkillRef } from "@/types/lighthouse-skills"; + +interface SkillMessageCardProps { + skillRef: LighthouseSkillRef; + context?: LighthouseContextEnvelope; +} + +// A skill launch rendered as the user turn (design 1f): the persisted ui_skill +// ref drives the card, so the hidden prompt never surfaces — not live, not +// after a reload. Falls back to the ref's own name for retired skill ids. +export function SkillMessageCard({ skillRef, context }: SkillMessageCardProps) { + const definition = getSkillById(skillRef.skillId); + const Icon = definition?.icon ?? Sparkles; + const findingLabel = context?.items.find( + (item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.FINDING, + )?.label; + + return ( +
    +
    + + + + Skill + + + {skillRef.name} + + + {findingLabel && ( + + {findingLabel} + + )} +
    +
    + ); +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/skill-run-progress.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/skill-run-progress.test.tsx new file mode 100644 index 0000000000..0db4555a0c --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/skill-run-progress.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { + createInitialLighthouseV2StreamState, + reduceLighthouseV2Event, +} from "@/app/(prowler)/lighthouse/_lib/event-reducer"; +import { getSkillById } from "@/lib/lighthouse/skills/registry"; + +import { SkillRunProgress } from "./skill-run-progress"; + +function buildStreamState() { + let state = createInitialLighthouseV2StreamState("task-1"); + state = reduceLighthouseV2Event(state, { + type: "message.delta", + content: "Gathering context.", + }); + state = reduceLighthouseV2Event(state, { + type: "tool_call.start", + toolCallId: "tool-1", + toolName: "get_finding", + }); + state = reduceLighthouseV2Event(state, { + type: "tool_call.end", + toolCallId: "tool-1", + outcome: "success", + }); + state = reduceLighthouseV2Event(state, { + type: "tool_call.start", + toolCallId: "tool-2", + toolName: "check_public_exposure", + }); + return state; +} + +const skill = (() => { + const definition = getSkillById("triage-decision"); + if (!definition) throw new Error("Expected skill definition"); + return definition; +})(); + +describe("SkillRunProgress", () => { + it("should surface the running tool as the live activity", () => { + // Given / When + render(); + + // Then + expect(screen.getByText("Triage Decision")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent( + "Running Check public exposure…", + ); + }); + + it("should show a thinking state while no tool is running", () => { + // Given: narration only — repeating the skill name here would duplicate + // the card title right above + let state = createInitialLighthouseV2StreamState("task-1"); + state = reduceLighthouseV2Event(state, { + type: "message.delta", + content: "Composing the verdict.", + }); + + // When + render(); + + // Then + expect(screen.getByRole("status")).toHaveTextContent("Thinking…"); + }); + + it("should expand into the timeline of tools as they actually ran", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("button", { name: /Triage Decision/ })); + + // Then: real tool calls in order, humanized + expect(screen.getByText("Get finding")).toBeInTheDocument(); + expect(screen.getByText("Check public exposure")).toBeInTheDocument(); + }); + + it("should stream narration and tool activity in order below the card", () => { + // Given / When + render(); + + // Then: the narration block is followed by the tool group it announced, + // matching how the persisted message renders after the run. + expect(screen.getByText("Gathering context.")).toBeInTheDocument(); + expect(screen.getByText("Using tools")).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/skill-run-progress.tsx b/ui/app/(prowler)/lighthouse/_components/chat/skill-run-progress.tsx new file mode 100644 index 0000000000..50231f8008 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/skill-run-progress.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { Bot, ChevronDown } from "lucide-react"; +import { useState, useSyncExternalStore } from "react"; + +import { + CHAIN_OF_THOUGHT_STATUS, + ChainOfThoughtStep, +} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought"; +import { + LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE, + LIGHTHOUSE_V2_TOOL_CALL_STATUS, + type LighthouseV2StreamState, + type LighthouseV2StreamToolCallActivityItem, +} from "@/app/(prowler)/lighthouse/_lib/event-reducer"; +import { formatToolName } from "@/app/(prowler)/lighthouse/_lib/tool-calls"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { cn } from "@/lib/utils"; +import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; + +import { StreamingActivityGroups } from "./streaming-message"; + +interface SkillRunProgressProps { + skill: LighthouseSkillDefinition; + streamState: LighthouseV2StreamState; + startedAt?: string; +} + +// Streaming view of a skill run. An LLM run is not deterministic, so there is +// no plan checklist and no percent bar — the card reports observed activity: +// collapsed, a pulsing lighthouse-gradient label naming the tool currently +// running (or the skill itself between tools); expanded, the append-only +// timeline of tool calls as they actually happened. Below, narration and tool +// activity stream interleaved in order, matching the persisted rendering. +export function SkillRunProgress({ + skill, + streamState, + startedAt, +}: SkillRunProgressProps) { + // Local state needed: the user toggles between compact card and timeline. + const [expanded, setExpanded] = useState(false); + const toolCallItems = streamState.activityItems.filter(isToolCallItem); + const lastToolCall = toolCallItems.at(-1); + // Between tools the model is generating text; naming the skill here would + // just repeat the card title, so the label reads "Thinking…" instead. + const activityLabel = + lastToolCall?.status === LIGHTHOUSE_V2_TOOL_CALL_STATUS.RUNNING + ? `Running ${formatToolName(lastToolCall.name)}…` + : "Thinking…"; + // Gate the body on narration: before the first text delta the card's own + // status label already reports the tool activity, so an items-only body + // would just duplicate it. + const hasNarration = streamState.activityItems.some( + (item) => item.type === LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TEXT, + ); + const Icon = skill.icon; + + return ( +
    + +
    +
    +
    + + {expanded ? ( + + ) : ( + + {activityLabel} + + )} +
    +
    + {hasNarration && ( +
    + +
    + )} +
    +
    + ); +} + +function SkillToolTimeline({ + toolCallItems, +}: { + toolCallItems: LighthouseV2StreamToolCallActivityItem[]; +}) { + if (toolCallItems.length === 0) { + return ( +

    + Waiting for the first tool call… +

    + ); + } + + return ( +
    + {toolCallItems.map((toolCall) => { + const isRunning = + toolCall.status === LIGHTHOUSE_V2_TOOL_CALL_STATUS.RUNNING; + return ( + + ); + })} +
    + ); +} + +function isToolCallItem( + item: LighthouseV2StreamState["activityItems"][number], +): item is LighthouseV2StreamToolCallActivityItem { + return item.type === LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TOOL_CALL; +} + +function ElapsedTime({ startedAt }: { startedAt?: string }) { + const elapsedSeconds = useElapsedSeconds(startedAt); + if (startedAt === undefined) return null; + const minutes = Math.floor(elapsedSeconds / 60); + const seconds = elapsedSeconds % 60; + return ( + <> + {" · "} + {String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")} + + ); +} + +// Ticking clock via useSyncExternalStore: the interval is the external store, +// and the floored second count keeps snapshots stable between ticks. +function useElapsedSeconds(startedAt?: string): number { + return useSyncExternalStore( + subscribeToClock, + () => getElapsedSeconds(startedAt), + // Stable server/hydration snapshot: a time-derived value would differ + // between the server render and the hydration pass. + () => 0, + ); +} + +function subscribeToClock(onStoreChange: () => void): () => void { + const intervalId = window.setInterval(onStoreChange, 1000); + return () => window.clearInterval(intervalId); +} + +function getElapsedSeconds(startedAt?: string): number { + if (!startedAt) return 0; + const started = new Date(startedAt).getTime(); + if (Number.isNaN(started)) return 0; + return Math.max(0, Math.floor((Date.now() - started) / 1000)); +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx b/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx index 0127ce5917..4a1021686b 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx @@ -1,6 +1,6 @@ "use client"; -import { Bot, Loader2 } from "lucide-react"; +import { Bot } from "lucide-react"; import { ChainOfThought, @@ -17,6 +17,7 @@ import { type LighthouseV2StreamToolCallActivityItem, } from "@/app/(prowler)/lighthouse/_lib/event-reducer"; import { formatToolName } from "@/app/(prowler)/lighthouse/_lib/tool-calls"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { cn } from "@/lib/utils"; import { MessageMarkdown } from "./message-markdown"; @@ -64,7 +65,9 @@ export function StreamingAssistantMessage({ ); } -function StreamingActivityGroups({ +// Ordered live view of the stream: narration blocks with tool groups between +// them. Shared with the skill progress card so both surfaces read identically. +export function StreamingActivityGroups({ streamState, }: { streamState: LighthouseV2StreamState; @@ -135,7 +138,7 @@ function StreamingToolCallGroup({ } icon={ toolCall.status === LIGHTHOUSE_V2_TOOL_CALL_STATUS.RUNNING - ? Loader2 + ? Spinner : undefined } label={getToolCallLabel(toolCall)} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx b/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx index c15849367e..3ec114065e 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx @@ -37,14 +37,20 @@ export function ToolCalls({ parts }: { parts: LighthouseV2Part[] }) { {label} - {parts.map((part, index) => ( - - ))} + ); } +// Flat row list without the "Used N tools" disclosure, for hosts that already +// provide their own collapse (e.g. the skill run receipt). +export function ToolCallList({ parts }: { parts: LighthouseV2Part[] }) { + return parts.map((part, index) => ( + + )); +} + function getToolCallsLabel(parts: LighthouseV2Part[]): string { if (parts.length === 1) { const toolCall = getToolCallContent(parts[0].content); diff --git a/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts b/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts index 1ca7eda565..caa98f2d6f 100644 --- a/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts +++ b/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLighthouseChatStore, + selectLighthouseChatActiveSkill, selectLighthouseChatCanSend, } from "@/app/(prowler)/lighthouse/_lib/chat-store"; import { @@ -14,6 +15,7 @@ import type { LighthouseV2SupportedModel, LighthouseV2SupportedProvider, } from "@/app/(prowler)/lighthouse/_types"; +import { getSkillById } from "@/lib/lighthouse/skills/registry"; import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; const { @@ -148,6 +150,78 @@ describe("createLighthouseChatStore", () => { }); }); + it("threads a launched skill through the optimistic message and the API call", async () => { + // Given + const store = makeStore(); + const skill = getSkillById("triage-decision"); + if (!skill) throw new Error("Expected skill definition"); + + // When + await store.getState().submitMessage(skill.name, undefined, skill); + + // Then + expect(sendMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + displayText: "Triage Decision", + skillId: "triage-decision", + }), + ); + expect(store.getState().messages.at(-1)?.parts[0]?.content).toMatchObject({ + text: expect.stringContaining("[PROWLER_UI_SKILL_V1]"), + display_text: "Triage Decision", + ui_skill: expect.objectContaining({ + skill_id: "triage-decision", + }), + }); + // The run is live, so the pill/progress surfaces see the active skill. + expect(selectLighthouseChatActiveSkill(store.getState())?.id).toBe( + "triage-decision", + ); + }); + + it("preserves an active skill stream while EventSource reconnects", async () => { + // Given + const store = makeStore(); + const skill = getSkillById("triage-decision"); + if (!skill) throw new Error("Expected skill definition"); + await store.getState().submitMessage(skill.name, undefined, skill); + eventSources[0].emit("message.delta", { + content: "Result ", + }); + + // When: the browser reports a transient failure and keeps reconnecting + eventSources[0].fail(0 /* EventSource.CONNECTING */); + eventSources[0].emit("message.delta", { + content: "Checking exposure.", + }); + + // Then + expect(store.getState().streamState).toMatchObject({ + status: "streaming", + activeTaskId: "task-1", + assistantText: "Result Checking exposure.", + }); + }); + + it("retries a failed skill launch with the same skill attached", async () => { + // Given + const store = makeStore(); + const skill = getSkillById("contextual-fix"); + if (!skill) throw new Error("Expected skill definition"); + sendMessageMock.mockResolvedValueOnce({ error: "Agent unavailable" }); + await store.getState().submitMessage(skill.name, undefined, skill); + // The failed run is no longer active, so the skill is not "active" either. + expect(selectLighthouseChatActiveSkill(store.getState())).toBeUndefined(); + + // When + await store.getState().retryLastMessage(); + + // Then + expect(sendMessageMock).toHaveBeenLastCalledWith( + expect.objectContaining({ skillId: "contextual-fix" }), + ); + }); + it("uses the model selected when submission starts", async () => { // Given const store = makeStore(); diff --git a/ui/app/(prowler)/lighthouse/_lib/chat-store.ts b/ui/app/(prowler)/lighthouse/_lib/chat-store.ts index f4d7bd488b..c67a2efb3d 100644 --- a/ui/app/(prowler)/lighthouse/_lib/chat-store.ts +++ b/ui/app/(prowler)/lighthouse/_lib/chat-store.ts @@ -8,6 +8,7 @@ import { } from "@/app/(prowler)/lighthouse/_actions"; import { createInitialLighthouseV2StreamState, + LIGHTHOUSE_V2_STREAM_STATUS, type LighthouseV2StreamState, reduceLighthouseV2Event, } from "@/app/(prowler)/lighthouse/_lib/event-reducer"; @@ -31,6 +32,7 @@ import { } from "@/app/(prowler)/lighthouse/_types"; import { prepareLighthouseContext } from "@/lib/lighthouse/context/compiler"; import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; +import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; export interface LighthouseChatConfig { configurations: LighthouseV2Configuration[]; @@ -72,6 +74,7 @@ export interface LighthouseChatState { submitMessage: ( displayText: string, context?: LighthouseContextEnvelope, + skill?: LighthouseSkillDefinition, ) => Promise; retryLastMessage: () => Promise; openSession: (sessionId: string) => Promise; @@ -83,6 +86,7 @@ export interface LighthouseChatState { export interface LighthouseChatSubmission { displayText: string; context?: LighthouseContextEnvelope; + skill?: LighthouseSkillDefinition; } export type LighthouseChatStore = StoreApi; @@ -104,6 +108,19 @@ export function selectLighthouseChatCanSend( ); } +// The skill whose run is currently visible in the stream. Derived, not stored: +// it is the last submission's skill for as long as that run is still active. +export function selectLighthouseChatActiveSkill( + state: LighthouseChatState, +): LighthouseSkillDefinition | undefined { + const skill = state.lastSubmission?.skill; + if (!skill) return undefined; + const isRunActive = + Boolean(state.streamState.activeTaskId) || + state.streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.STREAMING; + return isRunActive ? skill : undefined; +} + export function createLighthouseChatStore( options: CreateLighthouseChatStoreOptions, ): LighthouseChatStore { @@ -225,10 +242,10 @@ export function createLighthouseChatStore( // treat everything else as a reconnect. source.onerror = () => { if (eventSource !== source) return; - if (source.readyState === EventSource.CLOSED) { - closeStream(); - set({ feedback: "Unable to connect to the response stream." }); - } + if (source.readyState !== EventSource.CLOSED) return; + + closeStream(); + set({ feedback: "Unable to connect to the response stream." }); set((current) => ({ streamState: reduceLighthouseV2Event(current.streamState, { type: "disconnect", @@ -264,6 +281,7 @@ export function createLighthouseChatStore( const submitMessageInternal = async ( displayText: string, context?: LighthouseContextEnvelope, + skill?: LighthouseSkillDefinition, ): Promise => { if (!displayText.trim()) return; const selection = get().selectedModelSelection; @@ -288,9 +306,11 @@ export function createLighthouseChatStore( } const provisionalTaskId = `pending-${Date.now()}`; - const lastSubmission = contextSnapshot - ? { displayText, context: contextSnapshot } - : { displayText }; + const lastSubmission = { + displayText, + ...(contextSnapshot ? { context: contextSnapshot } : {}), + ...(skill ? { skill } : {}), + }; set((current) => ({ feedback: null, blockedByConflict: false, @@ -298,7 +318,7 @@ export function createLighthouseChatStore( input: "", messages: [ ...current.messages, - buildOptimisticMessage("user", displayText, contextSnapshot), + buildOptimisticMessage("user", displayText, contextSnapshot, skill), ], streamState: createInitialLighthouseV2StreamState(provisionalTaskId), })); @@ -312,6 +332,7 @@ export function createLighthouseChatStore( sessionId, displayText, ...(contextSnapshot ? { context: contextSnapshot } : {}), + ...(skill ? { skillId: skill.id } : {}), provider: selection.providerType, model: selection.modelId, }); @@ -401,13 +422,17 @@ export function createLighthouseChatStore( } }, - submitMessage: (displayText, context) => - submitMessageInternal(displayText, context), + submitMessage: (displayText, context, skill) => + submitMessageInternal(displayText, context, skill), retryLastMessage: async () => { const submission = get().lastSubmission; if (!submission) return; - await submitMessageInternal(submission.displayText, submission.context); + await submitMessageInternal( + submission.displayText, + submission.context, + submission.skill, + ); }, openSession: async (sessionId) => { diff --git a/ui/app/(prowler)/lighthouse/_lib/messages.test.ts b/ui/app/(prowler)/lighthouse/_lib/messages.test.ts index c155b77b7c..f5502607c2 100644 --- a/ui/app/(prowler)/lighthouse/_lib/messages.test.ts +++ b/ui/app/(prowler)/lighthouse/_lib/messages.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; +import { getSkillById } from "@/lib/lighthouse/skills/registry"; import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; import { buildOptimisticMessage, getLighthouseContext, + getSkillRef, getTextContent, } from "./messages"; @@ -118,4 +120,59 @@ describe("buildOptimisticMessage", () => { ui_context: expect.objectContaining({ schema_version: 1 }), }); }); + + it("should embed the skill instructions and the ui_skill ref when a skill launches", () => { + // Given + const skill = getSkillById("triage-decision"); + if (!skill) throw new Error("Expected skill definition"); + + // When + const message = buildOptimisticMessage( + "user", + skill.name, + undefined, + skill, + ); + + // Then + expect(message.parts[0]?.content).toMatchObject({ + text: expect.stringContaining("[PROWLER_UI_SKILL_V1]"), + display_text: "Triage Decision", + ui_skill: { + skill_id: "triage-decision", + name: "Triage Decision", + version: 1, + }, + }); + }); +}); + +describe("getSkillRef", () => { + it("should normalize a persisted ui_skill ref", () => { + // Given / When + const ref = getSkillRef({ + text: "prompt", + display_text: "Triage Decision", + ui_skill: { + skill_id: "triage-decision", + name: "Triage Decision", + version: 1, + }, + }); + + // Then + expect(ref).toEqual({ + skillId: "triage-decision", + name: "Triage Decision", + version: 1, + }); + }); + + it("should ignore content without a valid ui_skill", () => { + expect(getSkillRef({ text: "plain" })).toBeUndefined(); + expect( + getSkillRef({ text: "x", ui_skill: { skill_id: 4 } }), + ).toBeUndefined(); + expect(getSkillRef("string content")).toBeUndefined(); + }); }); diff --git a/ui/app/(prowler)/lighthouse/_lib/messages.ts b/ui/app/(prowler)/lighthouse/_lib/messages.ts index b1a3f711f2..10700bb7e3 100644 --- a/ui/app/(prowler)/lighthouse/_lib/messages.ts +++ b/ui/app/(prowler)/lighthouse/_lib/messages.ts @@ -1,14 +1,17 @@ import { + LIGHTHOUSE_V2_MESSAGE_ROLE, LIGHTHOUSE_V2_PART_TYPE, type LighthouseV2Message, type LighthouseV2MessageRole, } from "@/app/(prowler)/lighthouse/_types"; -import { - buildAgentText, - fromApiLighthouseContext, - toApiLighthouseContext, -} from "@/lib/lighthouse/context/transport"; +import { fromApiLighthouseContext } from "@/lib/lighthouse/context/transport"; +import { buildLighthouseMessageContent } from "@/lib/lighthouse/message-content"; +import { fromApiSkillRef } from "@/lib/lighthouse/skills/transport"; import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; +import type { + LighthouseSkillDefinition, + LighthouseSkillRef, +} from "@/types/lighthouse-skills"; // Message parts can arrive as a raw string or as a `{ text }` object; this // normalizes both to a plain string and ignores anything else. @@ -48,6 +51,57 @@ export function getLighthouseContext( return fromApiLighthouseContext(content.ui_context); } +// Reads the persisted skill reference back out of a user message part so the +// skill card survives reloads and history without parsing the prompt text. +export function getSkillRef(content: unknown): LighthouseSkillRef | undefined { + if ( + typeof content !== "object" || + content === null || + !("ui_skill" in content) + ) { + return undefined; + } + return fromApiSkillRef(content.ui_skill); +} + +// The user launch an assistant message responded to. +export interface SkillRunInfo { + ref: LighthouseSkillRef; + context?: LighthouseContextEnvelope; + launchedAt: string; +} + +// An assistant message is a "skill response" when the user turn right before +// it launched a skill; the persisted ui_skill/ui_context on that turn feed the +// receipt and the action row. +export function getSkillRunFromLaunch( + message: LighthouseV2Message, + previous: LighthouseV2Message | undefined, +): SkillRunInfo | undefined { + if ( + message.role !== LIGHTHOUSE_V2_MESSAGE_ROLE.ASSISTANT || + previous?.role !== LIGHTHOUSE_V2_MESSAGE_ROLE.USER + ) { + return undefined; + } + const textParts = previous.parts.filter( + (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT, + ); + const ref = textParts + .map((part) => getSkillRef(part.content)) + .find((skillRef) => skillRef !== undefined); + if (!ref) return undefined; + + const context = textParts + .map((part) => getLighthouseContext(part.content)) + .find((value) => value !== undefined); + return { + ref, + ...(context ? { context } : {}), + launchedAt: previous.insertedAt, + }; +} + // Monotonic counter guaranteeing unique optimistic ids even when two messages // are built within the same millisecond (toISOString alone is ms-granular). let optimisticMessageCounter = 0; @@ -58,6 +112,7 @@ export function buildOptimisticMessage( role: LighthouseV2MessageRole, displayText: string, context?: LighthouseContextEnvelope, + skill?: LighthouseSkillDefinition, ): LighthouseV2Message { const now = new Date().toISOString(); optimisticMessageCounter += 1; @@ -72,7 +127,7 @@ export function buildOptimisticMessage( { id: `${id}-part`, type: LIGHTHOUSE_V2_PART_TYPE.TEXT, - content: buildOptimisticContent(displayText, context), + content: buildLighthouseMessageContent(displayText, context, skill), toolCallOutcome: null, insertedAt: now, updatedAt: now, @@ -81,20 +136,6 @@ export function buildOptimisticMessage( }; } -function buildOptimisticContent( - displayText: string, - context?: LighthouseContextEnvelope, -) { - const apiContext = context ? toApiLighthouseContext(context) : undefined; - return context && apiContext - ? { - text: buildAgentText(displayText, apiContext), - display_text: displayText, - ui_context: apiContext, - } - : { text: displayText }; -} - // Derives a session title from the first user message (collapsed + truncated). export function buildSessionTitle(text: string): string { const normalized = text.replace(/\s+/g, " ").trim(); diff --git a/ui/app/(prowler)/lighthouse/_lib/panel-chat-store.test.ts b/ui/app/(prowler)/lighthouse/_lib/panel-chat-store.test.ts index 54704935d4..5e5b0e228c 100644 --- a/ui/app/(prowler)/lighthouse/_lib/panel-chat-store.test.ts +++ b/ui/app/(prowler)/lighthouse/_lib/panel-chat-store.test.ts @@ -7,10 +7,14 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({ updateLighthouseV2Configuration: vi.fn(), })); +import { getSkillById } from "@/lib/lighthouse/skills/registry"; + import type { LighthouseChatConfig } from "./chat-store"; import { + flushPendingPanelChatMessage, getOrCreatePanelChatStore, requestPanelChatMessage, + requestPanelSkillLaunch, resetPanelChatStoreForTests, } from "./panel-chat-store"; @@ -46,12 +50,54 @@ describe("panel chat message request", () => { expect(submitMessage).toHaveBeenCalledWith( "Analyze this finding", undefined, + undefined, ); expect(resetToNewChat.mock.invocationCallOrder[0]).toBeLessThan( submitMessage.mock.invocationCallOrder[0], ); }); + it("should submit a skill launch as a message titled after the skill", () => { + // Given + const store = getOrCreatePanelChatStore(EMPTY_CHAT_CONFIG); + const submitMessage = vi + .spyOn(store.getState(), "submitMessage") + .mockResolvedValue(); + const skill = getSkillById("triage-decision"); + if (!skill) throw new Error("Expected skill definition"); + + // When + requestPanelSkillLaunch(skill); + + // Then + expect(submitMessage).toHaveBeenCalledWith( + "Triage Decision", + undefined, + skill, + ); + }); + + it("should queue a skill launch until the panel store exists, then flush it", () => { + // Given: no store yet — the panel has not been opened/configured + const skill = getSkillById("systemic-scope"); + if (!skill) throw new Error("Expected skill definition"); + requestPanelSkillLaunch(skill); + + // When: the panel store is created later and the queue flushes + const store = getOrCreatePanelChatStore(EMPTY_CHAT_CONFIG); + const submitMessage = vi + .spyOn(store.getState(), "submitMessage") + .mockResolvedValue(); + flushPendingPanelChatMessage(); + + // Then + expect(submitMessage).toHaveBeenCalledWith( + "Systemic Scope", + undefined, + skill, + ); + }); + it("should cancel an initial submission before sending a contextual request", () => { // Given: the store is still creating its first session const store = getOrCreatePanelChatStore(EMPTY_CHAT_CONFIG); @@ -69,6 +115,7 @@ describe("panel chat message request", () => { expect(submitMessage).toHaveBeenCalledWith( "Analyze this finding", undefined, + undefined, ); }); }); diff --git a/ui/app/(prowler)/lighthouse/_lib/panel-chat-store.ts b/ui/app/(prowler)/lighthouse/_lib/panel-chat-store.ts index a4d04f5beb..b71cb56c86 100644 --- a/ui/app/(prowler)/lighthouse/_lib/panel-chat-store.ts +++ b/ui/app/(prowler)/lighthouse/_lib/panel-chat-store.ts @@ -5,6 +5,7 @@ import { type LighthouseChatStore, } from "@/app/(prowler)/lighthouse/_lib/chat-store"; import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; +import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; // Module-level singleton: the global side panel keeps the same conversation // while switching between Details and Lighthouse AI, across route navigation @@ -33,6 +34,26 @@ export function getOrCreatePanelChatStore( export function requestPanelChatMessage( displayText: string, context?: LighthouseContextEnvelope, +): void { + requestPanelChatSubmission({ displayText, ...(context ? { context } : {}) }); +} + +// Launching a skill is a regular panel chat message whose display text is the +// skill name; the skill definition rides along so the prompt and the ui_skill +// ref are attached at submit time. +export function requestPanelSkillLaunch( + skill: LighthouseSkillDefinition, + context?: LighthouseContextEnvelope, +): void { + requestPanelChatSubmission({ + displayText: skill.name, + ...(context ? { context } : {}), + skill, + }); +} + +function requestPanelChatSubmission( + submission: LighthouseChatSubmission, ): void { if (panelChatStore) { const chatState = panelChatStore.getState(); @@ -44,13 +65,17 @@ export function requestPanelChatMessage( if (hasActiveConversation) { chatState.resetToNewChat(); } - void panelChatStore.getState().submitMessage(displayText, context); + void panelChatStore + .getState() + .submitMessage( + submission.displayText, + submission.context, + submission.skill, + ); return; } - pendingPanelChatMessage = context - ? { displayText, context } - : { displayText }; + pendingPanelChatMessage = submission; } export function flushPendingPanelChatMessage(): void { @@ -60,7 +85,7 @@ export function flushPendingPanelChatMessage(): void { pendingPanelChatMessage = null; void panelChatStore .getState() - .submitMessage(message.displayText, message.context); + .submitMessage(message.displayText, message.context, message.skill); } // Lets the full-page surface reuse the singleton only when both surfaces point diff --git a/ui/app/(prowler)/lighthouse/_types/sessions.ts b/ui/app/(prowler)/lighthouse/_types/sessions.ts index fae08c5278..766c7b8574 100644 --- a/ui/app/(prowler)/lighthouse/_types/sessions.ts +++ b/ui/app/(prowler)/lighthouse/_types/sessions.ts @@ -1,4 +1,5 @@ import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; +import type { LighthouseSkillId } from "@/types/lighthouse-skills"; import type { LighthouseV2ProviderType } from "./config"; @@ -60,6 +61,10 @@ export interface LighthouseV2SendMessageInput { sessionId: string; displayText: string; context?: LighthouseContextEnvelope; + // Only the id crosses the server-action boundary (definitions hold React + // icon components, which are not serializable); the adapter resolves it + // against the UI-defined skills registry. + skillId?: LighthouseSkillId; provider: LighthouseV2ProviderType; model?: string | null; } diff --git a/ui/app/(prowler)/providers/providers-page.gcp.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.gcp.integration.test.tsx deleted file mode 100644 index e9f97a35ed..0000000000 --- a/ui/app/(prowler)/providers/providers-page.gcp.integration.test.tsx +++ /dev/null @@ -1,481 +0,0 @@ -import { describe, expect } from "vitest"; - -import { it } from "@/__tests__/fixtures"; -import { - buildGcpDiscoveryResult, - DISCOVERY_STATUS_VALUE, - GCP_BLOCKED_FOLDER, - GCP_BLOCKED_FOLDER_NAME, - GCP_BLOCKED_FOLDER_PROJECT, - GCP_CREATED_PROVIDER_IDS, - GCP_EMPTY_FOLDER, - GCP_EMPTY_FOLDER_NAME, - GCP_LONG_PROJECT_ID, - GCP_ORG_ID, - gcpOnboardingFixture, - mixedHierarchyFixture, - type OrgFixture, -} from "@/__tests__/msw/handlers/organizations.fixtures"; -import { ORGANIZATION_TYPE } from "@/types/organizations"; - -import { ProvidersPageHarness } from "./providers-page.harness"; - -// The GCP Organization flow end to end: method fork, setup, selection tree, apply, -// and the shared safety UX. Discovery timeout/keep-waiting/resume live in the -// submission unit tests instead, since they need fake timers. - -const VALID_SA_KEY = JSON.stringify({ - type: "service_account", - project_id: "prowler-scan", - private_key_id: "abcdef", - client_email: "prowler@prowler-scan.iam.gserviceaccount.com", -}); - -/** The GCP docs tutorial the wizard links to during the org flow. */ -const GCP_ORG_DOCS = "prowler-cloud-gcp-organizations"; - -/** The GCP organization seeded by `mixedHierarchyFixture`. */ -const GCP_ORG_NAME = "My GCP Organization"; -/** The GCP folder seeded by `mixedHierarchyFixture`, and its node id. */ -const GCP_FOLDER_NAME = "Engineering"; -const GCP_FOLDER_NODE_ID = "node-gcp-eng"; - -/** Drive a fresh GCP org onboarding up to the authentication submit. */ -async function authenticateGcpOrg( - harness: ProvidersPageHarness, - { orgId = GCP_ORG_ID, name }: { orgId?: string; name?: string } = {}, -): Promise { - await harness.mount(); - await harness.chooseGcpOrganizations(); - await harness.fillGcpOrgDetails(orgId, name); - await harness.submitOrganizationDetails(); - await harness.fillGcpServiceAccountKey(VALID_SA_KEY); - await harness.authenticate(); -} - -/** Drive a fresh GCP org onboarding up to the populated selection tree. */ -async function onboardGcpToSelection( - harness: ProvidersPageHarness, -): Promise { - await authenticateGcpOrg(harness, { name: "My GCP Org" }); - await harness.waitForSelectionTree(); - await harness.waitForProjectSelection(); -} - -interface ApplyRequestBody { - data: { - attributes: { - projects?: Array<{ project_id: string; alias?: string }>; - accounts?: unknown; - organizational_units?: unknown; - }; - }; -} - -describe("GCP Organizations onboarding (Phase 2)", () => { - it("completes the happy path: setup → discovery → selection → apply → connect → launch step", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - // Terminology: the selection step counts "projects", never "accounts". - expect(harness.hasSelectedProjectCount(2, 2)).toBe(true); - expect(harness.usesAccountWording()).toBe(false); - - await harness.testConnections(); - await harness.waitForProjectsConnected(); - - expect(harness.usesAccountWording()).toBe(false); - expect(harness.applyCallCount).toBe(1); - }, 40000); - - it("resolves the created providers' uids with one filtered list, and no include", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - await harness.testConnections(); - await harness.waitForProjectsConnected(); - - // The apply view rejects `include`, so the uids that map providers back to - // candidates are read from `/providers` — once for all of them, not once each. - expect(harness.applySentIncludeParam()).toBe(false); - expect(harness.providerUidLookupCount).toBe(1); - expect(harness.singleProviderFetchCount).toBe(0); - }, 40000); - - it("links the wizard docs to the GCP organizations tutorial", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await harness.mount(); - await harness.chooseGcpOrganizations(); - - expect(harness.hasDocsLinkTo(GCP_ORG_DOCS)).toBe(true); - }, 30000); - - it("nests each project under its discovered folder and renders every folder once", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - // Folder identity is the resource `name`, which is what children carry as - // `parent`; reading it from any other field flattens the tree. - expect(harness.countContainerRows("Engineering")).toBe(1); - expect(harness.countContainerRows("Platform")).toBe(1); - expect(harness.containerRowUids().sort()).toEqual([ - "folders/1000000001", - "folders/1000000002", - GCP_EMPTY_FOLDER, - GCP_BLOCKED_FOLDER, - ]); - - expect( - harness.isCandidateNestedUnder("prod-analytics", "Engineering"), - ).toBe(true); - expect(harness.isCandidateNestedUnder("prod-platform", "Platform")).toBe( - true, - ); - }, 40000); - - it("prefills a project alias with its display name, never its resource name", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - expect(harness.candidateAliasValue(/prod-analytics/)).toBe( - "Prod Analytics", - ); - expect(harness.candidateAliasValue(/prod-analytics/)).not.toMatch( - /^projects\//, - ); - }, 40000); - - it("marks a folder with nothing selectable as inert, in project wording", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - // Project-less folders do reach the tree, and clicking them selects nothing — - // which the row has to say, in GCP's own nouns. - expect(harness.isContainerInert(GCP_EMPTY_FOLDER_NAME)).toBe(true); - expect(harness.inertContainerNote(GCP_EMPTY_FOLDER_NAME)).toBe( - "No projects available to select in this folder.", - ); - expect(harness.isContainerInert(GCP_BLOCKED_FOLDER_NAME)).toBe(true); - - // A folder holding a ready project stays selectable. - expect(harness.isContainerInert("Engineering")).toBe(false); - expect(harness.inertContainerNote("Engineering")).toBeNull(); - expect(harness.usesAccountWording()).toBe(false); - }, 40000); - - it("still opens an inert folder so its blocked projects are visible", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - expect( - harness.isCandidateNestedUnder( - GCP_BLOCKED_FOLDER_PROJECT, - GCP_BLOCKED_FOLDER_NAME, - ), - ).toBe(true); - - // The row collapses and re-expands rather than selecting: an inert folder that - // could not be opened would never explain itself. - await harness.clickContainerRow(GCP_BLOCKED_FOLDER_NAME); - await harness.waitForTransition(); - expect(harness.isCandidateVisible(GCP_BLOCKED_FOLDER_PROJECT)).toBe(false); - - await harness.clickContainerRow(GCP_BLOCKED_FOLDER_NAME); - await harness.waitForTransition(); - expect(harness.isCandidateVisible(GCP_BLOCKED_FOLDER_PROJECT)).toBe(true); - expect(harness.hasSelectedProjectCount(2, 2)).toBe(true); - }, 40000); - - it("keeps a long project id inside its column instead of over the alias input", async () => { - const harness = new ProvidersPageHarness( - gcpOnboardingFixture({ - discovery: { - id: "disc-gcp-1", - status: DISCOVERY_STATUS_VALUE.SUCCEEDED, - result: buildGcpDiscoveryResult({ includeLongIdProject: true }), - error: null, - }, - }), - ); - await onboardGcpToSelection(harness); - - // Real layout, not class names: a leaf row that cannot shrink pushes the id - // over its neighbour instead of ellipsizing. - expect(harness.candidateRowOverflows(GCP_LONG_PROJECT_ID)).toBe(false); - }, 40000); - - // These three cases pin how `/schedules/bulk`'s per-provider lists are read: a - // client looking one level too deep sees no lists and cannot tell them apart. - it("launches the organization after a partial schedule save", async () => { - const harness = new ProvidersPageHarness( - gcpOnboardingFixture({ - scheduleBulk: { - updated: [GCP_CREATED_PROVIDER_IDS[0]], - failed: [{ id: GCP_CREATED_PROVIDER_IDS[1], error: "Denied" }], - shape: "flat", - }, - }), - ); - await onboardGcpToSelection(harness); - await harness.testConnections(); - await harness.waitForProjectsConnected(); - - await harness.enableInitialScan(); - await harness.saveScheduleAndLaunch(); - await harness.waitForPartialScheduleSave(1, 1); - - // The reason the API gave must reach the user — a count alone is unactionable. - expect(harness.hasScheduleFailureReason("Denied")).toBe(true); - expect(harness.organizationBulkScanCallCount).toBe(1); - }, 40000); - - it("keeps the user on the launch step when no schedule could be saved", async () => { - const harness = new ProvidersPageHarness( - gcpOnboardingFixture({ - scheduleBulk: { - updated: [], - failed: GCP_CREATED_PROVIDER_IDS.map((id) => ({ - id, - error: "Denied", - })), - shape: "flat", - }, - }), - ); - await onboardGcpToSelection(harness); - await harness.testConnections(); - await harness.waitForProjectsConnected(); - - await harness.enableInitialScan(); - await harness.saveScheduleAndLaunch(); - await harness.waitForScheduleSaveFailure(); - - expect(harness.hasScheduleFailureReason("Denied")).toBe(true); - expect(harness.isStillOnLaunchStep()).toBe(true); - expect(harness.organizationBulkScanCallCount).toBe(0); - }, 40000); - - it("proceeds when the schedule response carries no result lists", async () => { - // The POST commits each schedule before answering, so an unreadable body must - // not strand the user on a schedule that already exists. - const harness = new ProvidersPageHarness( - gcpOnboardingFixture({ - scheduleBulk: { updated: null, failed: [], shape: "bare" }, - }), - ); - await onboardGcpToSelection(harness); - await harness.testConnections(); - await harness.waitForProjectsConnected(); - - await harness.enableInitialScan(); - await harness.saveScheduleAndLaunch(); - await harness.waitForLaunchComplete(); - - expect(harness.organizationBulkScanCallCount).toBe(1); - }, 40000); - - it("sends a projects-only apply payload (no accounts, no organizational units)", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - await harness.testConnections(); - await harness.waitForProjectsConnected(); - - const body = await harness.lastRequestBody( - "POST", - "/apply", - ); - const attributes = body?.data.attributes; - expect(attributes?.projects?.map((p) => p.project_id).sort()).toEqual([ - "prod-analytics", - "prod-platform", - ]); - // GCP derives folder ancestors server-side, so no accounts/OUs are ever sent. - expect(attributes?.accounts).toBeUndefined(); - expect(attributes?.organizational_units).toBeUndefined(); - // No alias was typed, so none is included. - expect(attributes?.projects?.every((p) => p.alias === undefined)).toBe( - true, - ); - }, 40000); - - it("includes an alias only for projects the user renamed", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - await harness.setCandidateAlias(/prod-analytics/, "Analytics Prod"); - await harness.testConnections(); - await harness.waitForProjectsConnected(); - - const body = await harness.lastRequestBody( - "POST", - "/apply", - ); - const projects = body?.data.attributes.projects ?? []; - const analytics = projects.find((p) => p.project_id === "prod-analytics"); - const platform = projects.find((p) => p.project_id === "prod-platform"); - expect(analytics?.alias).toBe("Analytics Prod"); - expect(platform?.alias).toBeUndefined(); - }, 40000); - - it("disables blocked projects and excludes them from the selectable count", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - expect(await harness.isCandidateBlocked(/legacy-sandbox/)).toBe(true); - - // Two ready projects, the third (blocked) excluded from the count. - expect(harness.hasSelectedProjectCount(2, 2)).toBe(true); - expect(harness.hasSelectedProjectCount(3, 3)).toBe(false); - }, 40000); - - it("renders a folder as indeterminate when only some descendant projects are selected", async () => { - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await onboardGcpToSelection(harness); - - // Both ready projects start selected → the Engineering folder is fully checked. - expect(harness.candidateCheckboxState(/Engineering/)).toBe("true"); - - // Deselect one descendant project; its ancestor folder goes indeterminate. - await harness.toggleCandidate(/prod-platform/); - await harness.waitForSelectedProjectCount(1, 2); - expect(harness.candidateCheckboxState(/Engineering/)).toBe("mixed"); - }, 40000); - - it("warns before replacing an existing organization credential, then proceeds on confirm", async () => { - const fixture: OrgFixture = gcpOnboardingFixture({ - organizations: [ - { - id: "org-gcp-existing", - orgType: ORGANIZATION_TYPE.GCP, - name: "Existing GCP Org", - externalId: GCP_ORG_ID, - rootExternalId: null, - providerIds: ["gp-existing-1", "gp-existing-2"], - nodeIds: [], - secretId: "secret-gcp-existing", - }, - ], - }); - const harness = new ProvidersPageHarness(fixture); - await authenticateGcpOrg(harness); - - // The credential is replaced only after the user confirms, and the warning - // states how many onboarded providers that re-authenticates. - await harness.waitForCredentialReplaceWarning(); - expect(harness.hasCredentialReplaceProviderCount(2)).toBe(true); - - await harness.confirmCredentialReplace(); - - // Confirming updates the secret (PATCH) and continues into selection. - await harness.waitForSelectionTree(); - await harness.waitForProjectSelection(); - await harness.waitForSecretReplace(); - }, 40000); - - it("warns before an apply that overwrites already-onboarded project credentials", async () => { - const fixture = gcpOnboardingFixture({ - discovery: { - id: "disc-gcp-1", - status: DISCOVERY_STATUS_VALUE.SUCCEEDED, - result: buildGcpDiscoveryResult({ - replaceProjectIds: ["prod-analytics"], - }), - error: null, - }, - }); - const harness = new ProvidersPageHarness(fixture); - await onboardGcpToSelection(harness); - - await harness.testConnections(); - - // Pre-apply warning names the project whose credentials will be overwritten. - await harness.waitForCredentialReplaceWarning(); - expect(harness.hasApplyOverwriteWarning(1, ["Prod Analytics"])).toBe(true); - expect(harness.applyCallCount).toBe(0); - - await harness.confirmApplyOverwrite(); - await harness.waitForProjectsConnected(); - expect(harness.applyCallCount).toBe(1); - }, 40000); - - it("surfaces a failed discovery and retries with a fresh discovery", async () => { - const fixture = gcpOnboardingFixture({ - discovery: { - id: "disc-gcp-1", - status: DISCOVERY_STATUS_VALUE.FAILED, - result: {}, - error: "Service account lacks organization permissions", - }, - }); - const harness = new ProvidersPageHarness(fixture); - await authenticateGcpOrg(harness); - - await harness.waitForDiscoveryFailure(); - await harness.waitForDiscoveryCount(1); - - // Retry triggers a brand-new discovery, not a resumed poll. - await harness.retryDiscovery(); - await harness.waitForDiscoveryCount(2); - }, 40000); -}); - -describe("GCP Organizations connect step (Phase 2)", () => { - it("gates the GCP Organization method behind the cloud upgrade in OSS builds", async ({ - seedRuntimeConfig, - }) => { - seedRuntimeConfig({ cloudEnabled: false }); - const harness = new ProvidersPageHarness(gcpOnboardingFixture()); - await harness.mount(); - - await harness.selectProviderType(/Google Cloud Platform/); - await harness.waitForMethodStep(); - - // Choosing the org method must NOT start the flow in OSS. - await harness.chooseMethod(/Add Multiple Projects With GCP Organization/); - await harness.waitForMethodStep(); - expect(harness.hasOrganizationSetupStep()).toBe(false); - }, 30000); -}); - -describe("GCP Organizations providers page (Phase 2)", () => { - it("deletes a GCP folder with kind-aware copy and deletion-task polling", async () => { - const harness = new ProvidersPageHarness(mixedHierarchyFixture()); - await harness.mount({ openWizard: false }); - await harness.waitForNodeGroup(GCP_FOLDER_NAME); - - // GCP container nodes are "folders", not "organizational units". - await harness.openDeleteFolderFor(GCP_FOLDER_NAME); - - await harness.waitForDeleteConfirmation(); - expect(harness.hasDeleteWarningFor("folder")).toBe(true); - - await harness.confirmDelete(); - - await harness.waitForNodeDelete(GCP_FOLDER_NODE_ID); - // The polled task completes when the per-provider deletions are dispatched, so - // the copy may report acceptance and never that the folder is gone. - await harness.waitForTaskPoll("del-task-"); - await harness.waitForDeletionAccepted(); - expect(harness.claimsDeletionFinished()).toBe(false); - }, 30000); - - it("reports a failed deletion task instead of a false success", async () => { - const harness = new ProvidersPageHarness( - mixedHierarchyFixture({ deletionTaskState: "failed" }), - ); - await harness.mount({ openWizard: false }); - await harness.waitForOrganizationRow(GCP_ORG_NAME); - - await harness.openDeleteFor(GCP_ORG_NAME); - - await harness.waitForDeleteConfirmation(); - expect(harness.hasCascadeWarning(2)).toBe(true); - - await harness.confirmDelete(); - - // A failed task is reported as not completed, and the hierarchy refetched so - // the restored subtree reappears. - await harness.waitForDeletionFailure(); - }, 30000); -}); diff --git a/ui/app/(prowler)/providers/providers-page.harness.tsx b/ui/app/(prowler)/providers/providers-page.harness.tsx index 93f885cd15..4c10b5b07b 100644 --- a/ui/app/(prowler)/providers/providers-page.harness.tsx +++ b/ui/app/(prowler)/providers/providers-page.harness.tsx @@ -38,6 +38,17 @@ interface MountOptions { hierarchyFailure?: HierarchyReadFailure; } +/** + * Attributes a `POST /organizations` carries. `root_external_id` is deliberately + * absent: a test asserting the client sends none reads the parsed body, not this + * shape. + */ +export interface OrganizationCreateAttributes { + name?: string; + org_type?: string; + external_id?: string; +} + export class ProvidersPageHarness extends BrowserHarness { get applyCallCount(): number { return this.countRequests("POST", "/apply"); @@ -81,6 +92,27 @@ export class ProvidersPageHarness extends BrowserHarness { return this.countRequests("GET", "/organizations"); } + /** + * Attributes the organization was created with. Matched on the collection path + * itself rather than a substring: the nested `discover` and `apply` POSTs also + * live under `/organizations` and carry no body to read. + */ + async createdOrganizationAttributes(): Promise { + const entry = [...this.requestLog] + .reverse() + .find( + (r) => + r.method === "POST" && + new URL(r.url).pathname.endsWith("/organizations"), + ); + if (!entry) return null; + + const body = (await entry.request.clone().json()) as { + data?: { attributes?: OrganizationCreateAttributes }; + }; + return body?.data?.attributes ?? null; + } + /** * How many times the page fetched the organization hierarchy over HTTP — * either route, so this stays a tripwire for "the hierarchy is still @@ -157,7 +189,7 @@ export class ProvidersPageHarness extends BrowserHarness { await this.user.click(option); } - /** Click a method card in the AWS/GCP method selector by its title. */ + /** Click a method card in a provider's method selector by its title. */ async chooseMethod(name: RegExp): Promise { const card = await this.waitFor(() => this.byRoleName("radio", name)); await this.user.click(card); @@ -182,6 +214,20 @@ export class ProvidersPageHarness extends BrowserHarness { await this.waitForText(/Organization Details/); } + /** Select Azure and open the Management Group method card (no advance wait). */ + async chooseAzureOrganizationsMethod(): Promise { + await this.selectProviderType(/Microsoft Azure/); + await this.chooseMethod( + /Add Multiple Subscriptions With Azure Management Group/, + ); + } + + /** Enter the Azure Management Group onboarding flow from a fresh wizard. */ + async chooseAzureOrganizations(): Promise { + await this.chooseAzureOrganizationsMethod(); + await this.waitForText(/Organization Details/); + } + /** Wait until the wizard shows the selected provider's method fork. */ async waitForMethodStep(): Promise { await this.waitForText(/Select a method to add your/); @@ -219,6 +265,33 @@ export class ProvidersPageHarness extends BrowserHarness { await this.user.fill(textarea, json); } + // --- Wizard: Azure setup step ------------------------------------------ + + /** Fill the Azure organization-details phase (the tenant is the only id asked for). */ + async fillAzureOrgDetails(tenantId: string, name?: string): Promise { + const tenantInput = await this.waitFor(() => this.inputByName("tenantId")); + await this.user.fill(tenantInput, tenantId); + if (name !== undefined) { + const nameInput = this.inputByName("organizationName"); + if (nameInput) await this.user.fill(nameInput, name); + } + } + + /** Fill the Azure service-principal credentials on the authentication step. */ + async fillAzureCredentials( + clientId: string, + clientSecret: string, + ): Promise { + const clientIdInput = await this.waitFor(() => + this.inputByName("clientId"), + ); + await this.user.fill(clientIdInput, clientId); + const secretInput = await this.waitFor(() => + this.inputByName("clientSecret"), + ); + await this.user.fill(secretInput, clientSecret); + } + // --- Wizard: AWS setup step -------------------------------------------- async fillAwsOrgDetails(orgId: string, name?: string): Promise { @@ -292,18 +365,38 @@ export class ProvidersPageHarness extends BrowserHarness { /** * Whether the pre-apply warning states how many already-onboarded candidates the - * apply would overwrite, and names them. + * apply would overwrite, and names them — in the noun this organization type uses. */ + private hasOverwriteWarningFor( + count: number, + names: string[], + noun: string, + ): boolean { + const states = this.containsText( + new RegExp( + `overwrite the credentials of ${count} already-onboarded ${noun}`, + ), + ); + return states && names.every((name) => this.containsText(new RegExp(name))); + } + hasApplyOverwriteWarning( projectCount: number, names: string[] = [], ): boolean { - const states = this.containsText( - new RegExp( - `overwrite the credentials of ${projectCount} already-onboarded project`, - ), + return this.hasOverwriteWarningFor(projectCount, names, "project"); + } + + /** The same warning, in Azure's noun. */ + hasApplySubscriptionOverwriteWarning( + subscriptionCount: number, + names: string[] = [], + ): boolean { + return this.hasOverwriteWarningFor( + subscriptionCount, + names, + "subscription", ); - return states && names.every((name) => this.containsText(new RegExp(name))); } /** Confirm the pre-apply credential overwrite and continue into apply. */ @@ -316,6 +409,22 @@ export class ProvidersPageHarness extends BrowserHarness { await this.waitForText(/Authentication failed/, timeoutMs); } + /** + * Wait until a failed discovery surfaces the actionable copy for its machine + * code, rather than the type's generic authentication-failure fallback. + */ + async waitForDiscoveryFailureReason( + reason: RegExp, + timeoutMs = 15000, + ): Promise { + await this.waitForText(reason, timeoutMs); + } + + /** Whether that reason is showing — the negative half of the assertion. */ + hasDiscoveryFailureReason(reason: RegExp): boolean { + return this.containsText(reason); + } + /** Retry a failed/timed-out discovery with a fresh one. */ async retryDiscovery(): Promise { await this.clickButton(/Retry discovery/); @@ -342,11 +451,12 @@ export class ProvidersPageHarness extends BrowserHarness { return this.treeItems.find((el) => text.test(el.textContent ?? "")) ?? null; } - // Noun-agnostic on purpose: the copy says "accounts" for AWS, "projects" for GCP. + // Noun-agnostic on purpose: the copy says "accounts" for AWS, "projects" for + // GCP, "subscriptions" for Azure. private selectedCountText(): string { return ( this.container.textContent?.match( - /\d+ of \d+ (?:accounts|projects) selected/, + /\d+ of \d+ (?:accounts|projects|subscriptions) selected/, )?.[0] ?? "" ); } @@ -365,12 +475,44 @@ export class ProvidersPageHarness extends BrowserHarness { return this.waitFor(() => this.tree); } - /** A container row identifies itself by its uid: a GCP folder ref or an AWS OU id. */ - private static readonly CONTAINER_UID = /folders\/\d+|ou-[\w-]+/; + /** + * A container row's uid: a GCP folder ref, an AWS OU id, or an Azure Management + * Group resource id. Anchored because it is matched against one element's own + * value — the adjacent uid and name columns would otherwise run together. + */ + private static readonly CONTAINER_UID = + /^(?:folders\/\d+|ou-[\w-]+|\/providers\/Microsoft\.Management\/managementGroups\/[\w.()-]+)$/; + + /** + * The canonical uid an id column carries, which `TruncatedId` puts in its + * accessible name; the text fallback covers spans it did not render. + */ + private static columnUid(column: HTMLElement): string { + const uid = column.getAttribute("aria-label") ?? column.textContent ?? ""; + + return uid.trim(); + } + + /** A row's id column, found by the uid it resolves to. */ + private static idColumn(row: HTMLElement): HTMLElement | null { + return ( + Array.from(row.querySelectorAll("span")).find((span) => + ProvidersPageHarness.CONTAINER_UID.test( + ProvidersPageHarness.columnUid(span), + ), + ) ?? null + ); + } + + private static containerUid(row: HTMLElement): string | null { + const column = ProvidersPageHarness.idColumn(row); + + return column ? ProvidersPageHarness.columnUid(column) : null; + } private get containerRows(): HTMLElement[] { - return this.treeItems.filter((item) => - ProvidersPageHarness.CONTAINER_UID.test(item.textContent ?? ""), + return this.treeItems.filter( + (item) => ProvidersPageHarness.containerUid(item) !== null, ); } @@ -387,11 +529,19 @@ export class ProvidersPageHarness extends BrowserHarness { /** Uids rendered by container rows — a row with an unresolved id has none. */ containerRowUids(): string[] { return this.containerRows.flatMap( - (item) => - item.textContent?.match(ProvidersPageHarness.CONTAINER_UID) ?? [], + (item) => ProvidersPageHarness.containerUid(item) ?? [], ); } + /** Visible text of the id column whose accessible name is `uid`. */ + containerIdLabel(uid: string): string | null { + const column = Array.from( + this.container.querySelectorAll("[aria-label]"), + ).find((el) => el.getAttribute("aria-label") === uid); + + return column?.textContent?.trim() ?? null; + } + /** Whether a candidate row is rendered inside the subtree of a container. */ isCandidateNestedUnder( candidateUid: string, @@ -414,8 +564,11 @@ export class ProvidersPageHarness extends BrowserHarness { const row = this.containerRows.find((item) => (item.textContent ?? "").includes(containerLabel), ); + // The id column carries `role="img"` too, and comes first in the row, so the + // note is the one matched by its svg. return ( - row?.querySelector('[role="img"]')?.getAttribute("aria-label") ?? null + row?.querySelector('[role="img"]:has(svg)')?.getAttribute("aria-label") ?? + null ); } @@ -526,6 +679,28 @@ export class ProvidersPageHarness extends BrowserHarness { return this.hasSelectionSummary(selected, total, "projects"); } + /** Wait until subscription discovery finishes and the selection summary renders. */ + async waitForSubscriptionSelection(timeoutMs = 15000): Promise { + await this.waitForText(/of \d+ subscriptions selected/, timeoutMs); + } + + /** Wait until the summary reads " of subscriptions selected". */ + async waitForSelectedSubscriptionCount( + selected: number, + total: number, + timeoutMs = 15000, + ): Promise { + await this.waitForText( + new RegExp(`${selected} of ${total} subscriptions selected`), + timeoutMs, + ); + } + + /** Whether the summary reads " of subscriptions selected". */ + hasSelectedSubscriptionCount(selected: number, total: number): boolean { + return this.hasSelectionSummary(selected, total, "subscriptions"); + } + /** * Whether any visible copy uses the AWS candidate noun, which a GCP flow must * never say — the negative half of the terminology assertions. @@ -628,6 +803,11 @@ export class ProvidersPageHarness extends BrowserHarness { await this.waitForText(/Projects Connected!/, timeoutMs); } + /** Wait until every selected subscription has connected successfully. */ + async waitForSubscriptionsConnected(timeoutMs = 20000): Promise { + await this.waitForText(/Subscriptions Connected!/, timeoutMs); + } + /** Wait until the flow reaches the connected / ready-to-scan state. */ async waitForReadyToScan(timeoutMs = 20000): Promise { await this.waitForText(/Accounts Connected!|ready to Scan/, timeoutMs); @@ -698,6 +878,15 @@ export class ProvidersPageHarness extends BrowserHarness { return this.containsText(new RegExp(reason)); } + /** The href behind the success toast's "Go to scans" action. */ + scansToastHref(): string | null { + return ( + Array.from(this.container.querySelectorAll("a")) + .find((anchor) => /Go to scans/.test(anchor.textContent ?? "")) + ?.getAttribute("href") ?? null + ); + } + /** Whether the wizard is still showing the launch step (it did not navigate). */ isStillOnLaunchStep(): boolean { return this.containsText(/Scan Schedule/); @@ -835,6 +1024,12 @@ export class ProvidersPageHarness extends BrowserHarness { await this.clickMenuItem(/Delete Folder/); } + /** Open the delete flow for an Azure Management Group node. */ + async openDeleteManagementGroupFor(name: string): Promise { + await this.openActionsFor(name); + await this.clickMenuItem(/Delete Management Group/); + } + /** Wait until the wizard re-opens on the AWS authentication step. */ async waitForAuthenticationStep(): Promise { await this.waitForText( @@ -948,9 +1143,7 @@ export class ProvidersPageHarness extends BrowserHarness { /** Wait until the inline edit-name modal is open. */ async waitForEditNameModal(): Promise { - await this.waitForText( - /If left blank, Prowler will use the name stored in AWS/, - ); + await this.waitForText(/Edit Organization Name/); } async fillEditName(value: string): Promise { diff --git a/ui/app/(prowler)/providers/providers-page.hierarchy.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.hierarchy.integration.test.tsx deleted file mode 100644 index d7b7c8aec2..0000000000 --- a/ui/app/(prowler)/providers/providers-page.hierarchy.integration.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect } from "vitest"; - -// The extended `it` carries the auto `seedRuntimeConfig` fixture — grouping is -// cloud-only, so the runtime-config island must exist before mounting. -import { it } from "@/__tests__/fixtures"; -import { HIERARCHY_READ_FAILURE } from "@/__tests__/msw/handlers/organizations"; -import { - awsHierarchyFixture, - displayOnlyOrgHierarchyFixture, - mixedHierarchyFixture, -} from "@/__tests__/msw/handlers/organizations.fixtures"; - -import { ProvidersPageHarness } from "./providers-page.harness"; - -// Phase 1 new-behavior coverage (the Phase 0 AWS baseline suite stays untouched): -// the providers page now consumes the canonical organization-nodes contract for -// BOTH organization types, deriving container labels from node `kind`, and -// surfaces an explicit notice when the hierarchy fetch degrades. - -describe("Providers page — mixed AWS + GCP hierarchy display", () => { - it("groups both organizations, labelling nodes by kind (Organizational Unit vs Folder)", async () => { - const harness = new ProvidersPageHarness(mixedHierarchyFixture()); - await harness.mount({ openWizard: false }); - - // Both organizations render as top-level groups. - await harness.waitForOrganizationRow("My AWS Organization"); - await harness.waitForOrganizationRow("My GCP Organization"); - - // AWS organizational units and GCP folders both render as node groups. - await harness.waitForNodeGroup("Production"); - await harness.waitForNodeGroup("Sandbox"); - await harness.waitForNodeGroup("Engineering"); - await harness.waitForNodeGroup("Platform"); - - // Container labels are kind-driven, never ID-prefix-driven: AWS nodes read - // "Organizational Unit", GCP nodes read "Folder". - expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true); - expect(harness.hasNodeKindLabel("Folder")).toBe(true); - - // Per-organization provider counts. - expect(harness.hasProviderCount(3)).toBe(true); - expect(harness.hasProviderCount(2)).toBe(true); - - // Providers of both types render nested under their nodes, by alias. - expect(harness.hasProviderRow("prod-web")).toBe(true); - expect(harness.hasProviderRow("sandbox-1")).toBe(true); - expect(harness.hasProviderRow("Prod Analytics")).toBe(true); - expect(harness.hasProviderRow("Prod Platform")).toBe(true); - - // Tripwire: those rows came from a real fetch of the canonical route. - expect(harness.hierarchyFetchCount).toBeGreaterThan(0); - }, 30000); - - it("offers wizard re-entry to every organization type with a setup form", async () => { - const harness = new ProvidersPageHarness(mixedHierarchyFixture()); - await harness.mount({ openWizard: false }); - - await harness.waitForOrganizationRow("My GCP Organization"); - - // GCP now owns a setup form, so "Update Credentials" re-enters the wizard on - // it. Renaming is a plain PATCH either way. - const actions = await harness.actionLabelsFor("My GCP Organization"); - expect(actions).toContain("Edit Organization Name"); - expect(actions).toContain("Update Credentials"); - - // The AWS organization in the same table keeps it. - const awsActions = await harness.actionLabelsFor("My AWS Organization"); - expect(awsActions).toContain("Update Credentials"); - }, 30000); -}); - -describe("Providers page — organization type without an onboarding flow", () => { - it("groups it with its own wording and offers no wizard re-entry", async () => { - const harness = new ProvidersPageHarness(displayOnlyOrgHierarchyFixture()); - await harness.mount({ openWizard: false }); - - // Grouping is display-driven, so the organization still renders as a group. - await harness.waitForOrganizationRow("Contoso Tenant"); - expect(harness.hasProviderRow("contoso-prod")).toBe(true); - - // It never inherits AWS wording. - expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false); - - // Credential updates re-enter the organization wizard, which only exists for - // onboardable types; renaming is a plain PATCH and stays available. - const actions = await harness.actionLabelsFor("Contoso Tenant"); - expect(actions).toContain("Edit Organization Name"); - expect(actions).not.toContain("Update Credentials"); - }, 30000); -}); - -describe("Providers page — degraded hierarchy view", () => { - it("shows a non-blocking notice and keeps providers listed flat when hierarchy is unavailable", async () => { - const harness = new ProvidersPageHarness(awsHierarchyFixture()); - await harness.mount({ - openWizard: false, - hierarchyFailure: HIERARCHY_READ_FAILURE.ALL, - }); - - await harness.waitForDegradedHierarchyNotice(); - expect(harness.hasUngroupedProvidersNotice()).toBe(true); - - // Providers are still present despite grouping being unavailable, and no - // organization group row survives the failed hierarchy fetch. - expect(harness.hasProviderRow("prod-web")).toBe(true); - expect(harness.hasOrganizationRow("My AWS Organization")).toBe(false); - }, 30000); - - it("degrades the same way when only the node read fails", async () => { - // AWS accounts hang off the OUs, so the organization's own `providers` - // relationship is empty and its row drops out along with the nodes. - const harness = new ProvidersPageHarness(awsHierarchyFixture()); - await harness.mount({ - openWizard: false, - hierarchyFailure: HIERARCHY_READ_FAILURE.NODES, - }); - - await harness.waitForDegradedHierarchyNotice(); - expect(harness.hasUngroupedProvidersNotice()).toBe(true); - - await harness.waitForProviderRow("prod-web"); - expect(harness.hasProviderRow("sandbox-1")).toBe(true); - expect(harness.hasOrganizationRow("My AWS Organization")).toBe(false); - expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false); - }, 30000); - - it("shows no notice when the hierarchy is available", async () => { - const harness = new ProvidersPageHarness(awsHierarchyFixture()); - await harness.mount({ openWizard: false }); - - await harness.waitForOrganizationRow("My AWS Organization"); - expect(harness.hasDegradedHierarchyNotice()).toBe(false); - }, 30000); -}); diff --git a/ui/app/(prowler)/providers/providers-page.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.integration.test.tsx index f5d8d7c0d5..33b6cb91d0 100644 --- a/ui/app/(prowler)/providers/providers-page.integration.test.tsx +++ b/ui/app/(prowler)/providers/providers-page.integration.test.tsx @@ -1,11 +1,53 @@ import { describe, expect } from "vitest"; +// One integration file per real page: the whole providers page lives here. +// `it` comes from the fixtures module for its auto `seedRuntimeConfig` — grouping +// is cloud-only, so the runtime-config island must exist before mounting. import { it } from "@/__tests__/fixtures"; +import { HIERARCHY_READ_FAILURE } from "@/__tests__/msw/handlers/organizations"; import { + AWS_HIERARCHY_ORG_EXTERNAL_ID, awsHierarchyFixture, awsOnboardingFixture, + AZURE_BLOCKED_GROUP, + AZURE_BLOCKED_GROUP_NAME, + AZURE_BLOCKED_GROUP_SUBSCRIPTION, + AZURE_CREATED_PROVIDER_IDS, + AZURE_EMPTY_GROUP, + AZURE_EMPTY_GROUP_NAME, + AZURE_GROUP_ENGINEERING, + AZURE_GROUP_NODE_ID, + AZURE_GROUP_PLATFORM, + AZURE_HIERARCHY_CHILD_GROUP_NAME, + AZURE_HIERARCHY_GROUP_NAME, + AZURE_ORG_NAME, + AZURE_ROOT_GROUP, + AZURE_SUBSCRIPTION_DISABLED, + AZURE_SUBSCRIPTION_LEGACY, + AZURE_SUBSCRIPTION_PROD_EU, + AZURE_SUBSCRIPTION_PROD_US, + AZURE_TENANT_ID, + azureOnboardingFixture, + buildAzureDiscoveryResult, + buildGcpDiscoveryResult, + DISPLAY_ONLY_ORG_NAME, + DISPLAY_ONLY_PROVIDER_ALIAS, + displayOnlyOrgHierarchyFixture, + DISCOVERY_STATUS_VALUE, + GCP_BLOCKED_FOLDER, + GCP_BLOCKED_FOLDER_NAME, + GCP_BLOCKED_FOLDER_PROJECT, + GCP_CREATED_PROVIDER_IDS, + GCP_EMPTY_FOLDER, + GCP_EMPTY_FOLDER_NAME, + GCP_LONG_PROJECT_ID, + GCP_ORG_ID, + gcpOnboardingFixture, + mixedHierarchyFixture, type OrgFixture, } from "@/__tests__/msw/handlers/organizations.fixtures"; +import { ORGANIZATION_TYPE } from "@/types/organizations"; +import { SCAN_JOBS_TAB } from "@/types/scans"; import { ProvidersPageHarness } from "./providers-page.harness"; @@ -14,7 +56,6 @@ const AWS_ROLE_ARN = "arn:aws:iam::111111111111:role/ProwlerScan"; /** The organization id seeded by `awsHierarchyFixture`. */ const AWS_HIERARCHY_ORG_ID = "org-aws-1"; const AWS_ORG_NAME = "My AWS Organization"; -/** A world where one of the two ready accounts fails its connection test. */ const partialConnectionFixture = (): OrgFixture => awsOnboardingFixture({ connectionByUid: { @@ -37,187 +78,1382 @@ async function onboardToSelection( await harness.waitForAccountSelection(); } -describe("AWS Organizations onboarding (baseline)", () => { - it("completes the happy path: setup → discovery → selection → apply → connect → launch", async () => { - const harness = new ProvidersPageHarness(awsOnboardingFixture()); - await onboardToSelection(harness); - - await harness.testConnections(); - await harness.waitForAccountsConnected(); - - expect(harness.applyCallCount).toBe(1); - - // Drive the final action: save the schedules for both created providers and - // launch an initial scan for each. - await harness.enableInitialScan(); - await harness.saveScheduleAndLaunch(); - await harness.waitForLaunchComplete(); - - expect(harness.scheduleBulkCallCount).toBe(1); - expect(harness.organizationBulkScanCallCount).toBe(1); - }, 60000); - - it("disables blocked accounts and excludes them from the selectable count", async () => { - const harness = new ProvidersPageHarness(awsOnboardingFixture()); - await onboardToSelection(harness); - - expect(await harness.isAccountBlocked("333333333333")).toBe(true); - - await harness.waitForSelectedCount(2, 2); - expect(harness.hasSelectedCount(3, 3)).toBe(false); - }, 40000); - - it("retries only the failed connections without re-applying", async () => { - const harness = new ProvidersPageHarness(partialConnectionFixture()); - await onboardToSelection(harness); - - await harness.testConnections(); - await harness.waitForConnectionError(); - await harness.waitForConnectionAttempts(2); - expect(harness.applyCallCount).toBe(1); - - await harness.testConnections(); - await harness.waitForConnectionAttempts(3); - expect(harness.applyCallCount).toBe(1); - }, 60000); - - it("settles each account the moment its own test finishes", async () => { - // Given — one account connects on the first read, the other stays running. - const harness = new ProvidersPageHarness( - awsOnboardingFixture({ - connectionByUid: { - "111111111111": { connected: true }, - "222222222222": { connected: true, executingPolls: 2 }, - }, - }), - ); - await onboardToSelection(harness); - - // When - await harness.testConnections(); - await harness.waitForCandidateConnectionState(/111111111111/, "success"); - - // Then — the finished account reports while the slow one is still testing. - expect(harness.candidateConnectionState(/222222222222/)).toBe("testing"); - - await harness.waitForAccountsConnected(); - }, 60000); - - it("re-applies when the selection changes after an apply", async () => { - const harness = new ProvidersPageHarness(partialConnectionFixture()); - await onboardToSelection(harness); - - await harness.testConnections(); - await harness.waitForConnectionError(); - expect(harness.applyCallCount).toBe(1); - - await harness.goBack(); - await harness.toggleAccount("222222222222"); - await harness.testConnections(); - await harness.waitForApplyCount(2); - }, 60000); - - it("allows skipping validation once at least one account connected", async () => { - const harness = new ProvidersPageHarness(partialConnectionFixture()); - await onboardToSelection(harness); - - await harness.testConnections(); - await harness.waitForConnectionError(); - - await harness.skipValidation(); - await harness.waitForReadyToScan(); - }, 60000); +const VALID_SA_KEY = JSON.stringify({ + type: "service_account", + project_id: "prowler-scan", + private_key_id: "abcdef", + client_email: "prowler@prowler-scan.iam.gserviceaccount.com", }); -describe("AWS Organizations providers page (baseline)", () => { - it("groups providers under their organization and OUs with kind-driven labels", async () => { - const harness = new ProvidersPageHarness(awsHierarchyFixture()); - await harness.mount({ openWizard: false }); +const GCP_ORG_DOCS = "prowler-cloud-gcp-organizations"; - // Organization group row + its OU sub-groups (expanded by default in cloud). - await harness.waitForOrganizationRow(AWS_ORG_NAME); - await harness.waitForNodeGroup("Production"); - await harness.waitForNodeGroup("Sandbox"); +/** The GCP organization seeded by `mixedHierarchyFixture`. */ +const GCP_ORG_NAME = "My GCP Organization"; +/** The GCP folder seeded by `mixedHierarchyFixture`, and its node id. */ +const GCP_FOLDER_NAME = "Engineering"; +const GCP_FOLDER_NODE_ID = "node-gcp-eng"; - // Node group rows are labelled by kind, not by ID prefix. - expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true); - // Organization row surfaces its total provider count. - expect(harness.hasProviderCount(3)).toBe(true); +/** Drive a fresh GCP org onboarding up to the authentication submit. */ +async function authenticateGcpOrg( + harness: ProvidersPageHarness, + { orgId = GCP_ORG_ID, name }: { orgId?: string; name?: string } = {}, +): Promise { + await harness.mount(); + await harness.chooseGcpOrganizations(); + await harness.fillGcpOrgDetails(orgId, name); + await harness.submitOrganizationDetails(); + await harness.fillGcpServiceAccountKey(VALID_SA_KEY); + await harness.authenticate(); +} - // Providers render nested under their OU, addressed by alias. - expect(harness.hasProviderRow("prod-web")).toBe(true); - expect(harness.hasProviderRow("prod-api")).toBe(true); - expect(harness.hasProviderRow("sandbox-1")).toBe(true); +/** Drive a fresh GCP org onboarding up to the populated selection tree. */ +async function onboardGcpToSelection( + harness: ProvidersPageHarness, +): Promise { + await authenticateGcpOrg(harness, { name: "My GCP Org" }); + await harness.waitForSelectionTree(); + await harness.waitForProjectSelection(); +} - // Tripwire: the rows above came from real requests, so a loader that stops - // fetching the hierarchy (either route) fails here instead of staying green - // on harness-supplied data. - expect(harness.organizationFetchCount).toBeGreaterThan(0); - expect(harness.hierarchyFetchCount).toBeGreaterThan(0); - }, 30000); +interface ApplyProjectRequest { + project_id: string; + alias?: string; +} - it("edits the organization name via the inline modal (PATCH)", async () => { - const harness = new ProvidersPageHarness(awsHierarchyFixture()); - await harness.mount({ openWizard: false }); - await harness.waitForOrganizationRow(AWS_ORG_NAME); +interface ApplySubscriptionRequest { + subscription_id: string; + alias?: string; +} - await harness.openEditNameFor(AWS_ORG_NAME); +interface ApplyRequestAttributes { + projects?: ApplyProjectRequest[]; + subscriptions?: ApplySubscriptionRequest[]; + accounts?: unknown; + organizational_units?: unknown; +} - // The edit-name affordance is an inline modal (not the wizard) today. - await harness.waitForEditNameModal(); - await harness.fillEditName("Renamed AWS Org"); - await harness.saveName(); +interface ApplyRequestData { + attributes: ApplyRequestAttributes; +} - await harness.waitForOrganizationRename(AWS_HIERARCHY_ORG_ID); - }, 30000); +interface ApplyRequestBody { + data: ApplyRequestData; +} - it("re-enters the wizard at the authentication step to update credentials", async () => { - const harness = new ProvidersPageHarness(awsHierarchyFixture()); - await harness.mount({ openWizard: false }); - await harness.waitForOrganizationRow(AWS_ORG_NAME); +interface RenameRequestAttributes { + name?: string; +} - await harness.openUpdateCredentialsFor(AWS_ORG_NAME); +interface RenameRequestData { + attributes: RenameRequestAttributes; +} - // Opens the org wizard directly on the AWS authentication (access) phase. - await harness.waitForAuthenticationStep(); - expect(harness.hasAuthenticateButton()).toBe(true); - // Edit-credentials re-entry skips the details step, so Back is hidden. - expect(harness.hasBackButton()).toBe(false); - }, 30000); +interface RenameRequestBody { + data: RenameRequestData; +} - it("deletes an organization with a cascade warning and deletion-task polling", async () => { - const harness = new ProvidersPageHarness(awsHierarchyFixture()); - await harness.mount({ openWizard: false }); - await harness.waitForOrganizationRow(AWS_ORG_NAME); +interface OrganizationSecretAttributes { + secret_type?: string; + secret?: Record; +} - await harness.openDeleteFor(AWS_ORG_NAME); +interface OrganizationSecretRequestData { + attributes: OrganizationSecretAttributes; +} - // Cascade confirmation dialog, stating the affected provider count. - await harness.waitForDeleteConfirmation(); - expect(harness.hasDeleteWarning()).toBe(true); - expect(harness.hasCascadeWarning(3)).toBe(true); +interface OrganizationSecretRequestBody { + data: OrganizationSecretRequestData; +} - await harness.confirmDelete(); +const AZURE_CLIENT_ID = "99999999-9999-4999-8999-999999999999"; +const AZURE_CLIENT_SECRET = "azure-client-secret"; +const AZURE_ORG_DOCS = "prowler-cloud-azure-management-groups"; - await harness.waitForOrganizationDelete(AWS_HIERARCHY_ORG_ID); - // Deletion is a 202 + task: nothing is reported until the UI has polled it. - await harness.waitForTaskPoll(); - }, 30000); +/** Drive a fresh Azure org onboarding up to the authentication submit. */ +async function authenticateAzureOrg( + harness: ProvidersPageHarness, + { name }: { name?: string } = {}, +): Promise { + await harness.mount(); + await harness.chooseAzureOrganizations(); + await harness.fillAzureOrgDetails(AZURE_TENANT_ID, name); + await harness.submitOrganizationDetails(); + await harness.fillAzureCredentials(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET); + await harness.authenticate(); +} - it("renders a flat provider list (no org/OU grouping) on-prem", async ({ - seedRuntimeConfig, - }) => { - seedRuntimeConfig({ cloudEnabled: false }); - const harness = new ProvidersPageHarness(awsHierarchyFixture()); - await harness.mount({ openWizard: false }); +/** Drive a fresh Azure org onboarding up to the populated selection tree. */ +async function onboardAzureToSelection( + harness: ProvidersPageHarness, +): Promise { + await authenticateAzureOrg(harness, { name: "My Azure Org" }); + await harness.waitForSelectionTree(); + await harness.waitForSubscriptionSelection(); +} - // Providers still render, but ungrouped: no organization row, no OU labels. - await harness.waitForProviderRow("prod-web"); - expect(harness.hasProviderRow("prod-api")).toBe(true); - expect(harness.hasProviderRow("sandbox-1")).toBe(true); - expect(harness.hasOrganizationRow(AWS_ORG_NAME)).toBe(false); - expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false); - // On-prem never asks for the organization hierarchy at all. - expect(harness.hierarchyFetchCount).toBe(0); - }, 30000); +describe("Organization onboarding wizard", () => { + describe("AWS Organizations", () => { + describe("Full onboarding run", () => { + it("completes the happy path: setup → discovery → selection → apply → connect → launch", async () => { + const harness = new ProvidersPageHarness(awsOnboardingFixture()); + await onboardToSelection(harness); + + await harness.testConnections(); + await harness.waitForAccountsConnected(); + + expect(harness.applyCallCount).toBe(1); + + await harness.enableInitialScan(); + await harness.saveScheduleAndLaunch(); + await harness.waitForLaunchComplete(); + + expect(harness.scheduleBulkCallCount).toBe(1); + expect(harness.organizationBulkScanCallCount).toBe(1); + }, 60000); + }); + + describe("Account selection", () => { + it("disables blocked accounts and excludes them from the selectable count", async () => { + const harness = new ProvidersPageHarness(awsOnboardingFixture()); + await onboardToSelection(harness); + + expect(await harness.isAccountBlocked("333333333333")).toBe(true); + + await harness.waitForSelectedCount(2, 2); + expect(harness.hasSelectedCount(3, 3)).toBe(false); + }, 40000); + }); + + describe("Connection testing", () => { + it("retries only the failed connections without re-applying", async () => { + const harness = new ProvidersPageHarness(partialConnectionFixture()); + await onboardToSelection(harness); + + await harness.testConnections(); + await harness.waitForConnectionError(); + await harness.waitForConnectionAttempts(2); + expect(harness.applyCallCount).toBe(1); + + await harness.testConnections(); + await harness.waitForConnectionAttempts(3); + expect(harness.applyCallCount).toBe(1); + }, 60000); + + it("settles each account the moment its own test finishes", async () => { + const harness = new ProvidersPageHarness( + awsOnboardingFixture({ + connectionByUid: { + "111111111111": { connected: true }, + "222222222222": { connected: true, executingPolls: 2 }, + }, + }), + ); + await onboardToSelection(harness); + + await harness.testConnections(); + await harness.waitForCandidateConnectionState( + /111111111111/, + "success", + ); + + expect(harness.candidateConnectionState(/222222222222/)).toBe( + "testing", + ); + + await harness.waitForAccountsConnected(); + }, 60000); + + it("re-applies when the selection changes after an apply", async () => { + const harness = new ProvidersPageHarness(partialConnectionFixture()); + await onboardToSelection(harness); + + await harness.testConnections(); + await harness.waitForConnectionError(); + expect(harness.applyCallCount).toBe(1); + + await harness.goBack(); + await harness.toggleAccount("222222222222"); + await harness.testConnections(); + await harness.waitForApplyCount(2); + }, 60000); + + it("allows skipping validation once at least one account connected", async () => { + const harness = new ProvidersPageHarness(partialConnectionFixture()); + await onboardToSelection(harness); + + await harness.testConnections(); + await harness.waitForConnectionError(); + + await harness.skipValidation(); + await harness.waitForReadyToScan(); + }, 60000); + }); + }); + + describe("GCP Organizations", () => { + describe("Full onboarding run", () => { + it("completes the happy path: setup → discovery → selection → apply → connect → launch step", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + // Terminology: the selection step counts "projects", never "accounts". + expect(harness.hasSelectedProjectCount(2, 2)).toBe(true); + expect(harness.usesAccountWording()).toBe(false); + + await harness.testConnections(); + await harness.waitForProjectsConnected(); + + expect(harness.usesAccountWording()).toBe(false); + expect(harness.applyCallCount).toBe(1); + }, 40000); + }); + + describe("Wizard entry", () => { + it("links the wizard docs to the GCP organizations tutorial", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await harness.mount(); + await harness.chooseGcpOrganizations(); + + expect(harness.hasDocsLinkTo(GCP_ORG_DOCS)).toBe(true); + }, 30000); + + it("gates the GCP Organization method behind the cloud upgrade in OSS builds", async ({ + seedRuntimeConfig, + }) => { + seedRuntimeConfig({ cloudEnabled: false }); + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await harness.mount(); + + await harness.selectProviderType(/Google Cloud Platform/); + await harness.waitForMethodStep(); + + await harness.chooseMethod( + /Add Multiple Projects With GCP Organization/, + ); + await harness.waitForMethodStep(); + expect(harness.hasOrganizationSetupStep()).toBe(false); + }, 30000); + }); + + describe("Project selection", () => { + it("nests each project under its discovered folder and renders every folder once", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + // Folder identity is the resource `name`, which is what children carry as + // `parent`; reading it from any other field flattens the tree. + expect(harness.countContainerRows("Engineering")).toBe(1); + expect(harness.countContainerRows("Platform")).toBe(1); + expect(harness.containerRowUids().sort()).toEqual([ + "folders/1000000001", + "folders/1000000002", + GCP_EMPTY_FOLDER, + GCP_BLOCKED_FOLDER, + ]); + + // A folder ref is short, so visible text and accessible name coincide. + expect(harness.containerIdLabel("folders/1000000001")).toBe( + "folders/1000000001", + ); + expect(harness.containerIdLabel(GCP_EMPTY_FOLDER)).toBe( + GCP_EMPTY_FOLDER, + ); + + expect( + harness.isCandidateNestedUnder("prod-analytics", "Engineering"), + ).toBe(true); + expect( + harness.isCandidateNestedUnder("prod-platform", "Platform"), + ).toBe(true); + }, 40000); + + it("prefills a project alias with its display name, never its resource name", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + expect(harness.candidateAliasValue(/prod-analytics/)).toBe( + "Prod Analytics", + ); + expect(harness.candidateAliasValue(/prod-analytics/)).not.toMatch( + /^projects\//, + ); + }, 40000); + + it("marks a folder with nothing selectable as inert, in project wording", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + expect(harness.isContainerInert(GCP_EMPTY_FOLDER_NAME)).toBe(true); + expect(harness.inertContainerNote(GCP_EMPTY_FOLDER_NAME)).toBe( + "No projects available to select in this folder.", + ); + expect(harness.isContainerInert(GCP_BLOCKED_FOLDER_NAME)).toBe(true); + + expect(harness.isContainerInert("Engineering")).toBe(false); + expect(harness.inertContainerNote("Engineering")).toBeNull(); + expect(harness.usesAccountWording()).toBe(false); + }, 40000); + + it("still opens an inert folder so its blocked projects are visible", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + expect( + harness.isCandidateNestedUnder( + GCP_BLOCKED_FOLDER_PROJECT, + GCP_BLOCKED_FOLDER_NAME, + ), + ).toBe(true); + + // The row collapses and re-expands rather than selecting. + await harness.clickContainerRow(GCP_BLOCKED_FOLDER_NAME); + await harness.waitForTransition(); + expect(harness.isCandidateVisible(GCP_BLOCKED_FOLDER_PROJECT)).toBe( + false, + ); + + await harness.clickContainerRow(GCP_BLOCKED_FOLDER_NAME); + await harness.waitForTransition(); + expect(harness.isCandidateVisible(GCP_BLOCKED_FOLDER_PROJECT)).toBe( + true, + ); + expect(harness.hasSelectedProjectCount(2, 2)).toBe(true); + }, 40000); + + it("keeps a long project id inside its column instead of over the alias input", async () => { + const harness = new ProvidersPageHarness( + gcpOnboardingFixture({ + discovery: { + id: "disc-gcp-1", + status: DISCOVERY_STATUS_VALUE.SUCCEEDED, + result: buildGcpDiscoveryResult({ includeLongIdProject: true }), + error: null, + }, + }), + ); + await onboardGcpToSelection(harness); + + // Real layout boxes, not class names: an unshrinkable row pushes the id out. + expect(harness.candidateRowOverflows(GCP_LONG_PROJECT_ID)).toBe(false); + }, 40000); + + it("disables blocked projects and excludes them from the selectable count", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + expect(await harness.isCandidateBlocked(/legacy-sandbox/)).toBe(true); + + expect(harness.hasSelectedProjectCount(2, 2)).toBe(true); + expect(harness.hasSelectedProjectCount(3, 3)).toBe(false); + }, 40000); + + it("renders a folder as indeterminate when only some descendant projects are selected", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + expect(harness.candidateCheckboxState(/Engineering/)).toBe("true"); + + await harness.toggleCandidate(/prod-platform/); + await harness.waitForSelectedProjectCount(1, 2); + expect(harness.candidateCheckboxState(/Engineering/)).toBe("mixed"); + }, 40000); + }); + + describe("Apply payload", () => { + it("sends a projects-only apply payload (no accounts, no organizational units)", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + await harness.testConnections(); + await harness.waitForProjectsConnected(); + + const body = await harness.lastRequestBody( + "POST", + "/apply", + ); + const attributes = body?.data.attributes; + expect(attributes?.projects?.map((p) => p.project_id).sort()).toEqual([ + "prod-analytics", + "prod-platform", + ]); + // GCP derives folder ancestors server-side, so no accounts/OUs are ever sent. + expect(attributes?.accounts).toBeUndefined(); + expect(attributes?.organizational_units).toBeUndefined(); + expect(attributes?.projects?.every((p) => p.alias === undefined)).toBe( + true, + ); + }, 40000); + + it("includes an alias only for projects the user renamed", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + await harness.setCandidateAlias(/prod-analytics/, "Analytics Prod"); + await harness.testConnections(); + await harness.waitForProjectsConnected(); + + const body = await harness.lastRequestBody( + "POST", + "/apply", + ); + const projects = body?.data.attributes.projects ?? []; + // A payload that dropped `prod-platform` would pass the optional lookups. + expect(projects.map((p) => p.project_id).sort()).toEqual([ + "prod-analytics", + "prod-platform", + ]); + const analytics = projects.find( + (p) => p.project_id === "prod-analytics", + ); + const platform = projects.find((p) => p.project_id === "prod-platform"); + expect(analytics?.alias).toBe("Analytics Prod"); + expect(platform?.alias).toBeUndefined(); + }, 40000); + + it("resolves the created providers' uids with one filtered list, and no include", async () => { + const harness = new ProvidersPageHarness(gcpOnboardingFixture()); + await onboardGcpToSelection(harness); + + await harness.testConnections(); + await harness.waitForProjectsConnected(); + + // The apply view rejects `include`, so the uids that map providers back to + // candidates are read from `/providers` — once for all of them, not once each. + expect(harness.applySentIncludeParam()).toBe(false); + expect(harness.providerUidLookupCount).toBe(1); + expect(harness.singleProviderFetchCount).toBe(0); + }, 40000); + }); + + describe("Credential conflicts", () => { + it("warns before replacing an existing organization credential, then proceeds on confirm", async () => { + const fixture: OrgFixture = gcpOnboardingFixture({ + organizations: [ + { + id: "org-gcp-existing", + orgType: ORGANIZATION_TYPE.GCP, + name: "Existing GCP Org", + externalId: GCP_ORG_ID, + rootExternalId: null, + providerIds: ["gp-existing-1", "gp-existing-2"], + nodeIds: [], + secretId: "secret-gcp-existing", + }, + ], + }); + const harness = new ProvidersPageHarness(fixture); + await authenticateGcpOrg(harness); + + await harness.waitForCredentialReplaceWarning(); + expect(harness.hasCredentialReplaceProviderCount(2)).toBe(true); + + await harness.confirmCredentialReplace(); + + await harness.waitForSelectionTree(); + await harness.waitForProjectSelection(); + await harness.waitForSecretReplace(); + }, 40000); + + it("warns before an apply that overwrites already-onboarded project credentials", async () => { + const fixture = gcpOnboardingFixture({ + discovery: { + id: "disc-gcp-1", + status: DISCOVERY_STATUS_VALUE.SUCCEEDED, + result: buildGcpDiscoveryResult({ + replaceProjectIds: ["prod-analytics"], + }), + error: null, + }, + }); + const harness = new ProvidersPageHarness(fixture); + await onboardGcpToSelection(harness); + + await harness.testConnections(); + + await harness.waitForCredentialReplaceWarning(); + expect(harness.hasApplyOverwriteWarning(1, ["Prod Analytics"])).toBe( + true, + ); + expect(harness.applyCallCount).toBe(0); + + await harness.confirmApplyOverwrite(); + await harness.waitForProjectsConnected(); + expect(harness.applyCallCount).toBe(1); + }, 40000); + }); + + describe("Discovery failures", () => { + it("surfaces a failed discovery and retries with a fresh discovery", async () => { + const fixture = gcpOnboardingFixture({ + discovery: { + id: "disc-gcp-1", + status: DISCOVERY_STATUS_VALUE.FAILED, + result: {}, + error: "Service account lacks organization permissions", + }, + }); + const harness = new ProvidersPageHarness(fixture); + await authenticateGcpOrg(harness); + + await harness.waitForDiscoveryFailure(); + await harness.waitForDiscoveryCount(1); + + await harness.retryDiscovery(); + await harness.waitForDiscoveryCount(2); + }, 40000); + }); + + describe("Launch and scheduling", () => { + it("launches the organization after a partial schedule save", async () => { + const harness = new ProvidersPageHarness( + gcpOnboardingFixture({ + scheduleBulk: { + updated: [GCP_CREATED_PROVIDER_IDS[0]], + failed: [{ id: GCP_CREATED_PROVIDER_IDS[1], error: "Denied" }], + shape: "flat", + }, + }), + ); + await onboardGcpToSelection(harness); + await harness.testConnections(); + await harness.waitForProjectsConnected(); + + await harness.enableInitialScan(); + await harness.saveScheduleAndLaunch(); + await harness.waitForPartialScheduleSave(1, 1); + + // The reason the API gave must reach the user — a count alone is unactionable. + expect(harness.hasScheduleFailureReason("Denied")).toBe(true); + expect(harness.organizationBulkScanCallCount).toBe(1); + // Derived independently of the legacy path's tab, so nothing else covers it. + expect(harness.scansToastHref()).toBe( + `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`, + ); + }, 40000); + + it("keeps the user on the launch step when no schedule could be saved", async () => { + const harness = new ProvidersPageHarness( + gcpOnboardingFixture({ + scheduleBulk: { + updated: [], + failed: GCP_CREATED_PROVIDER_IDS.map((id) => ({ + id, + error: "Denied", + })), + shape: "flat", + }, + }), + ); + await onboardGcpToSelection(harness); + await harness.testConnections(); + await harness.waitForProjectsConnected(); + + await harness.enableInitialScan(); + await harness.saveScheduleAndLaunch(); + await harness.waitForScheduleSaveFailure(); + + expect(harness.hasScheduleFailureReason("Denied")).toBe(true); + expect(harness.isStillOnLaunchStep()).toBe(true); + expect(harness.organizationBulkScanCallCount).toBe(0); + }, 40000); + + it("proceeds when the schedule response carries no result lists", async () => { + // The POST commits each schedule before answering, so an unreadable body must + // not strand the user on a schedule that already exists. + const harness = new ProvidersPageHarness( + gcpOnboardingFixture({ + scheduleBulk: { updated: null, failed: [], shape: "bare" }, + }), + ); + await onboardGcpToSelection(harness); + await harness.testConnections(); + await harness.waitForProjectsConnected(); + + await harness.enableInitialScan(); + await harness.saveScheduleAndLaunch(); + await harness.waitForLaunchComplete(); + + expect(harness.organizationBulkScanCallCount).toBe(1); + }, 40000); + }); + }); + + describe("Azure Organizations", () => { + describe("Full onboarding run", () => { + it("completes the happy path: setup → discovery → selection → apply → connect → launch", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + // Terminology: the selection step counts "subscriptions", never "accounts". + expect(harness.hasSelectedSubscriptionCount(2, 2)).toBe(true); + expect(harness.usesAccountWording()).toBe(false); + + // The tenant is the whole identity on the wire: the API derives the root + // Management Group itself, so the client must not send one. + const created = await harness.createdOrganizationAttributes(); + expect(created?.org_type).toBe(ORGANIZATION_TYPE.AZURE); + expect(created?.external_id).toBe(AZURE_TENANT_ID); + expect(created).not.toHaveProperty("root_external_id"); + + await harness.testConnections(); + await harness.waitForSubscriptionsConnected(); + + expect(harness.usesAccountWording()).toBe(false); + expect(harness.applyCallCount).toBe(1); + + await harness.enableInitialScan(); + await harness.saveScheduleAndLaunch(); + await harness.waitForLaunchComplete(); + + expect(harness.scheduleBulkCallCount).toBe(1); + expect(harness.organizationBulkScanCallCount).toBe(1); + }, 60000); + + it("sends only the service-principal credentials as the organization secret", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + const secret = + await harness.lastRequestBody( + "POST", + "/organization-secrets", + ); + // The tenant lives on the organization; repeating it inside the secret + // would be a second source of truth. + expect(secret?.data.attributes.secret).toEqual({ + client_id: AZURE_CLIENT_ID, + client_secret: AZURE_CLIENT_SECRET, + }); + expect(secret?.data.attributes.secret).not.toHaveProperty("tenant_id"); + }, 40000); + }); + + describe("Wizard entry", () => { + it("links the wizard docs to the Azure Management Groups tutorial", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await harness.mount(); + await harness.chooseAzureOrganizations(); + + expect(harness.hasDocsLinkTo(AZURE_ORG_DOCS)).toBe(true); + }, 30000); + + it("gates the Azure Management Group method behind the cloud upgrade in OSS builds", async ({ + seedRuntimeConfig, + }) => { + seedRuntimeConfig({ cloudEnabled: false }); + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await harness.mount(); + + await harness.selectProviderType(/Microsoft Azure/); + await harness.waitForMethodStep(); + + await harness.chooseMethod( + /Add Multiple Subscriptions With Azure Management Group/, + ); + await harness.waitForMethodStep(); + expect(harness.hasOrganizationSetupStep()).toBe(false); + }, 30000); + }); + + describe("Subscription selection", () => { + it("nests each subscription under its management group and renders every group once", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + // Management Group identity is the canonical resource id, which is what + // children carry as `parent_id`; reading the bare name flattens the tree. + expect(harness.countContainerRows("Engineering")).toBe(1); + expect(harness.countContainerRows("Platform")).toBe(1); + expect(harness.containerRowUids().sort()).toEqual([ + AZURE_BLOCKED_GROUP, + AZURE_GROUP_ENGINEERING, + AZURE_EMPTY_GROUP, + AZURE_GROUP_PLATFORM, + ]); + + // Every group's canonical id repeats the same ARM prefix, so the id column + // shows the trailing name and keeps the canonical id as its accessible name. + expect(harness.containerIdLabel(AZURE_GROUP_ENGINEERING)).toBe( + "engineering", + ); + expect(harness.containerIdLabel(AZURE_GROUP_PLATFORM)).toBe("platform"); + expect(harness.containerIdLabel(AZURE_EMPTY_GROUP)).toBe("holding"); + expect(harness.containerIdLabel(AZURE_BLOCKED_GROUP)).toBe("archived"); + + expect( + harness.isCandidateNestedUnder( + AZURE_SUBSCRIPTION_PROD_EU, + "Engineering", + ), + ).toBe(true); + expect( + harness.isCandidateNestedUnder( + AZURE_SUBSCRIPTION_PROD_US, + "Platform", + ), + ).toBe(true); + }, 40000); + + it("prefills a subscription alias with its display name, never its subscription id", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + const alias = harness.candidateAliasValue( + new RegExp(AZURE_SUBSCRIPTION_PROD_EU), + ); + expect(alias).toBe("Production EU"); + expect(alias).not.toBe(AZURE_SUBSCRIPTION_PROD_EU); + }, 40000); + + it("marks a management group with nothing selectable as inert, in subscription wording", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + expect(harness.isContainerInert(AZURE_EMPTY_GROUP_NAME)).toBe(true); + expect(harness.inertContainerNote(AZURE_EMPTY_GROUP_NAME)).toBe( + "No subscriptions available to select in this management group.", + ); + expect(harness.isContainerInert(AZURE_BLOCKED_GROUP_NAME)).toBe(true); + + expect(harness.isContainerInert("Engineering")).toBe(false); + expect(harness.inertContainerNote("Engineering")).toBeNull(); + expect(harness.usesAccountWording()).toBe(false); + }, 40000); + + it("still opens an inert management group so its blocked subscriptions are visible", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + expect( + harness.isCandidateNestedUnder( + AZURE_BLOCKED_GROUP_SUBSCRIPTION, + AZURE_BLOCKED_GROUP_NAME, + ), + ).toBe(true); + + // The row collapses and re-expands rather than selecting. + await harness.clickContainerRow(AZURE_BLOCKED_GROUP_NAME); + await harness.waitForTransition(); + expect( + harness.isCandidateVisible(AZURE_BLOCKED_GROUP_SUBSCRIPTION), + ).toBe(false); + + await harness.clickContainerRow(AZURE_BLOCKED_GROUP_NAME); + await harness.waitForTransition(); + expect( + harness.isCandidateVisible(AZURE_BLOCKED_GROUP_SUBSCRIPTION), + ).toBe(true); + expect(harness.hasSelectedSubscriptionCount(2, 2)).toBe(true); + }, 40000); + + it("keeps a long subscription id inside its column instead of over the alias input", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + // Subscription ids are UUIDs, so every row is the long-id case — no opt-in + // candidate needed. Real layout boxes, not class names. + expect(harness.candidateRowOverflows(AZURE_SUBSCRIPTION_PROD_EU)).toBe( + false, + ); + }, 40000); + + it("disables blocked subscriptions and excludes them from the selectable count", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + expect( + await harness.isCandidateBlocked( + new RegExp(AZURE_SUBSCRIPTION_LEGACY), + ), + ).toBe(true); + + // Blocked on `state != "Enabled"` alone — no provider and no linkage + // conflict, the one blocked class the conflict cases cannot reach. + expect( + await harness.isCandidateBlocked( + new RegExp(AZURE_SUBSCRIPTION_DISABLED), + ), + ).toBe(true); + + expect(harness.hasSelectedSubscriptionCount(2, 2)).toBe(true); + expect(harness.hasSelectedSubscriptionCount(4, 4)).toBe(false); + }, 40000); + + it("renders a management group as indeterminate when only some descendant subscriptions are selected", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + expect(harness.candidateCheckboxState(/Engineering/)).toBe("true"); + + await harness.toggleCandidate(new RegExp(AZURE_SUBSCRIPTION_PROD_US)); + await harness.waitForSelectedSubscriptionCount(1, 2); + expect(harness.candidateCheckboxState(/Engineering/)).toBe("mixed"); + }, 40000); + }); + + describe("Apply payload", () => { + it("sends a subscriptions-only apply payload (no accounts, no projects)", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + await harness.testConnections(); + await harness.waitForSubscriptionsConnected(); + + const body = await harness.lastRequestBody( + "POST", + "/apply", + ); + const attributes = body?.data.attributes; + expect( + attributes?.subscriptions?.map((s) => s.subscription_id).sort(), + ).toEqual([AZURE_SUBSCRIPTION_PROD_EU, AZURE_SUBSCRIPTION_PROD_US]); + // Azure derives Management Group ancestors server-side, so nothing else is + // sent — and never another provider's noun. + expect(attributes?.accounts).toBeUndefined(); + expect(attributes?.organizational_units).toBeUndefined(); + expect(attributes?.projects).toBeUndefined(); + expect( + attributes?.subscriptions?.every((s) => s.alias === undefined), + ).toBe(true); + }, 40000); + + it("includes an alias only for subscriptions the user renamed", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + await harness.setCandidateAlias( + new RegExp(AZURE_SUBSCRIPTION_PROD_EU), + "Contoso EU", + ); + await harness.testConnections(); + await harness.waitForSubscriptionsConnected(); + + const body = await harness.lastRequestBody( + "POST", + "/apply", + ); + const subscriptions = body?.data.attributes.subscriptions ?? []; + const renamed = subscriptions.find( + (s) => s.subscription_id === AZURE_SUBSCRIPTION_PROD_EU, + ); + const untouched = subscriptions.find( + (s) => s.subscription_id === AZURE_SUBSCRIPTION_PROD_US, + ); + expect(renamed?.alias).toBe("Contoso EU"); + expect(untouched?.alias).toBeUndefined(); + }, 40000); + + it("resolves the created providers' uids with one filtered list, and no include", async () => { + const harness = new ProvidersPageHarness(azureOnboardingFixture()); + await onboardAzureToSelection(harness); + + await harness.testConnections(); + await harness.waitForSubscriptionsConnected(); + + expect(harness.applySentIncludeParam()).toBe(false); + expect(harness.providerUidLookupCount).toBe(1); + expect(harness.singleProviderFetchCount).toBe(0); + }, 40000); + }); + + describe("Credential conflicts", () => { + it("warns before replacing an existing organization credential, then proceeds on confirm", async () => { + const fixture: OrgFixture = azureOnboardingFixture({ + organizations: [ + { + id: "org-azure-existing", + orgType: ORGANIZATION_TYPE.AZURE, + name: "Existing Azure Org", + externalId: AZURE_TENANT_ID, + rootExternalId: AZURE_ROOT_GROUP, + providerIds: ["azp-existing-1", "azp-existing-2"], + nodeIds: [], + secretId: "secret-azure-existing", + }, + ], + }); + const harness = new ProvidersPageHarness(fixture); + await authenticateAzureOrg(harness); + + await harness.waitForCredentialReplaceWarning(); + expect(harness.hasCredentialReplaceProviderCount(2)).toBe(true); + + await harness.confirmCredentialReplace(); + + await harness.waitForSelectionTree(); + await harness.waitForSubscriptionSelection(); + await harness.waitForSecretReplace(); + }, 40000); + + it("warns before an apply that overwrites already-onboarded subscription credentials", async () => { + const fixture = azureOnboardingFixture({ + discovery: { + id: "disc-azure-1", + status: DISCOVERY_STATUS_VALUE.SUCCEEDED, + result: buildAzureDiscoveryResult({ + replaceSubscriptionIds: [AZURE_SUBSCRIPTION_PROD_EU], + }), + error: null, + }, + }); + const harness = new ProvidersPageHarness(fixture); + await onboardAzureToSelection(harness); + + await harness.testConnections(); + + await harness.waitForCredentialReplaceWarning(); + expect( + harness.hasApplySubscriptionOverwriteWarning(1, ["Production EU"]), + ).toBe(true); + expect(harness.applyCallCount).toBe(0); + + await harness.confirmApplyOverwrite(); + await harness.waitForSubscriptionsConnected(); + expect(harness.applyCallCount).toBe(1); + }, 40000); + }); + + describe("Discovery failures", () => { + it("surfaces a failed discovery and retries with a fresh discovery", async () => { + const fixture = azureOnboardingFixture({ + discovery: { + id: "disc-azure-1", + status: DISCOVERY_STATUS_VALUE.FAILED, + result: {}, + error: "azure_insufficient_permissions", + errorMessage: + "The Azure credential cannot read the complete hierarchy.", + }, + }); + const harness = new ProvidersPageHarness(fixture); + await authenticateAzureOrg(harness); + + // Curated copy for a known code outranks the server's own message: ours + // names the fix, and the credentials are not the problem here. + await harness.waitForDiscoveryFailureReason( + /Grant it the Reader role at the Management Group level/, + ); + expect( + harness.hasDiscoveryFailureReason( + /cannot read the complete hierarchy\./, + ), + ).toBe(false); + await harness.waitForDiscoveryCount(1); + + await harness.retryDiscovery(); + await harness.waitForDiscoveryCount(2); + }, 40000); + + it("falls back to the server's message for a failure code it has no copy for", async () => { + const fixture = azureOnboardingFixture({ + discovery: { + id: "disc-azure-1", + status: DISCOVERY_STATUS_VALUE.FAILED, + result: {}, + error: "azure_quota_exceeded", + errorMessage: + "Azure throttled the Management Group read for this tenant.", + }, + }); + const harness = new ProvidersPageHarness(fixture); + await authenticateAzureOrg(harness); + + // An unmapped code must still be specific: the sanitized server message, + // never the raw code and never "authentication failed". + await harness.waitForDiscoveryFailureReason( + /Azure throttled the Management Group read for this tenant\./, + ); + expect(harness.hasDiscoveryFailureReason(/azure_quota_exceeded/)).toBe( + false, + ); + expect(harness.hasDiscoveryFailureReason(/Authentication failed/)).toBe( + false, + ); + }, 40000); + }); + + describe("Launch and scheduling", () => { + it("launches the organization after a partial schedule save", async () => { + const harness = new ProvidersPageHarness( + azureOnboardingFixture({ + scheduleBulk: { + updated: [AZURE_CREATED_PROVIDER_IDS[0]], + failed: [{ id: AZURE_CREATED_PROVIDER_IDS[1], error: "Denied" }], + shape: "flat", + }, + }), + ); + await onboardAzureToSelection(harness); + await harness.testConnections(); + await harness.waitForSubscriptionsConnected(); + + await harness.enableInitialScan(); + await harness.saveScheduleAndLaunch(); + await harness.waitForPartialScheduleSave(1, 1); + + expect(harness.hasScheduleFailureReason("Denied")).toBe(true); + expect(harness.organizationBulkScanCallCount).toBe(1); + }, 40000); + + it("keeps the user on the launch step when no schedule could be saved", async () => { + const harness = new ProvidersPageHarness( + azureOnboardingFixture({ + scheduleBulk: { + updated: [], + failed: AZURE_CREATED_PROVIDER_IDS.map((id) => ({ + id, + error: "Denied", + })), + shape: "flat", + }, + }), + ); + await onboardAzureToSelection(harness); + await harness.testConnections(); + await harness.waitForSubscriptionsConnected(); + + await harness.enableInitialScan(); + await harness.saveScheduleAndLaunch(); + await harness.waitForScheduleSaveFailure(); + + expect(harness.hasScheduleFailureReason("Denied")).toBe(true); + expect(harness.isStillOnLaunchStep()).toBe(true); + expect(harness.organizationBulkScanCallCount).toBe(0); + }, 40000); + + it("proceeds when the schedule response carries no result lists", async () => { + const harness = new ProvidersPageHarness( + azureOnboardingFixture({ + scheduleBulk: { updated: null, failed: [], shape: "bare" }, + }), + ); + await onboardAzureToSelection(harness); + await harness.testConnections(); + await harness.waitForSubscriptionsConnected(); + + await harness.enableInitialScan(); + await harness.saveScheduleAndLaunch(); + await harness.waitForLaunchComplete(); + + expect(harness.organizationBulkScanCallCount).toBe(1); + }, 40000); + }); + }); +}); + +describe("Providers page", () => { + describe("Organization hierarchy display", () => { + describe("AWS Organizations", () => { + it("groups providers under their organization and OUs with kind-driven labels", async () => { + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ openWizard: false }); + + await harness.waitForOrganizationRow(AWS_ORG_NAME); + await harness.waitForNodeGroup("Production"); + await harness.waitForNodeGroup("Sandbox"); + + expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true); + expect(harness.hasProviderCount(3)).toBe(true); + + expect(harness.hasProviderRow("prod-web")).toBe(true); + expect(harness.hasProviderRow("prod-api")).toBe(true); + expect(harness.hasProviderRow("sandbox-1")).toBe(true); + + // Tripwire: a loader that stops fetching the hierarchy (either route) fails + // here instead of staying green on harness-supplied data. + expect(harness.organizationFetchCount).toBeGreaterThan(0); + expect(harness.hierarchyFetchCount).toBeGreaterThan(0); + }, 30000); + }); + + describe("Mixed AWS + Azure + GCP", () => { + it("groups every organization, labelling nodes by kind (Organizational Unit vs Management Group vs Folder)", async () => { + const harness = new ProvidersPageHarness(mixedHierarchyFixture()); + await harness.mount({ openWizard: false }); + + await harness.waitForOrganizationRow("My AWS Organization"); + await harness.waitForOrganizationRow(AZURE_ORG_NAME); + await harness.waitForOrganizationRow("My GCP Organization"); + + await harness.waitForNodeGroup("Production"); + await harness.waitForNodeGroup("Sandbox"); + await harness.waitForNodeGroup(AZURE_HIERARCHY_GROUP_NAME); + // Nested Management Groups keep their own row rather than collapsing into + // their parent. + await harness.waitForNodeGroup(AZURE_HIERARCHY_CHILD_GROUP_NAME); + await harness.waitForNodeGroup("Engineering"); + await harness.waitForNodeGroup("Platform"); + + // Labels are kind-driven, never ID-prefix-driven. + expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true); + expect(harness.hasNodeKindLabel("Management Group")).toBe(true); + expect(harness.hasNodeKindLabel("Folder")).toBe(true); + + expect(harness.hasProviderCount(3)).toBe(true); + expect(harness.hasProviderCount(2)).toBe(true); + + expect(harness.hasProviderRow("prod-web")).toBe(true); + expect(harness.hasProviderRow("sandbox-1")).toBe(true); + expect(harness.hasProviderRow("Contoso EU")).toBe(true); + expect(harness.hasProviderRow("Contoso US")).toBe(true); + expect(harness.hasProviderRow("Prod Analytics")).toBe(true); + expect(harness.hasProviderRow("Prod Platform")).toBe(true); + + // Tripwire: those rows came from a real fetch of the canonical route. + expect(harness.hierarchyFetchCount).toBeGreaterThan(0); + }, 30000); + }); + + describe("Organization type without an onboarding flow", () => { + it("groups it with its own wording and offers no wizard re-entry", async () => { + const harness = new ProvidersPageHarness( + displayOnlyOrgHierarchyFixture(), + ); + await harness.mount({ openWizard: false }); + + // Grouping is display-driven, so the organization still renders as a group. + await harness.waitForOrganizationRow(DISPLAY_ONLY_ORG_NAME); + expect(harness.hasProviderRow(DISPLAY_ONLY_PROVIDER_ALIAS)).toBe(true); + + // Neither another provider's container wording nor a crash: the type has no + // vocabulary of its own in this build. + expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false); + expect(harness.hasNodeKindLabel("Management Group")).toBe(false); + + // The wizard only exists for onboardable types; renaming is a plain PATCH. + const actions = await harness.actionLabelsFor(DISPLAY_ONLY_ORG_NAME); + expect(actions).toContain("Edit Organization Name"); + expect(actions).not.toContain("Update Credentials"); + }, 30000); + }); + + describe("On-prem builds", () => { + it("renders a flat provider list (no org/OU grouping) on-prem", async ({ + seedRuntimeConfig, + }) => { + seedRuntimeConfig({ cloudEnabled: false }); + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ openWizard: false }); + + await harness.waitForProviderRow("prod-web"); + expect(harness.hasProviderRow("prod-api")).toBe(true); + expect(harness.hasProviderRow("sandbox-1")).toBe(true); + expect(harness.hasOrganizationRow(AWS_ORG_NAME)).toBe(false); + expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false); + // On-prem never asks for the organization hierarchy at all. + expect(harness.hierarchyFetchCount).toBe(0); + }, 30000); + }); + + describe("Degraded hierarchy reads", () => { + it("shows a non-blocking notice and keeps providers listed flat when hierarchy is unavailable", async () => { + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ + openWizard: false, + hierarchyFailure: HIERARCHY_READ_FAILURE.ALL, + }); + + await harness.waitForDegradedHierarchyNotice(); + expect(harness.hasUngroupedProvidersNotice()).toBe(true); + + expect(harness.hasProviderRow("prod-web")).toBe(true); + expect(harness.hasOrganizationRow("My AWS Organization")).toBe(false); + }, 30000); + + it("degrades the same way when only the node read fails", async () => { + // AWS accounts hang off the OUs, so the organization's own `providers` + // relationship is empty and its row drops out along with the nodes. + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ + openWizard: false, + hierarchyFailure: HIERARCHY_READ_FAILURE.NODES, + }); + + await harness.waitForDegradedHierarchyNotice(); + expect(harness.hasUngroupedProvidersNotice()).toBe(true); + + await harness.waitForProviderRow("prod-web"); + expect(harness.hasProviderRow("sandbox-1")).toBe(true); + expect(harness.hasOrganizationRow("My AWS Organization")).toBe(false); + expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false); + }, 30000); + + it("shows no notice when the hierarchy is available", async () => { + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ openWizard: false }); + + await harness.waitForOrganizationRow("My AWS Organization"); + expect(harness.hasDegradedHierarchyNotice()).toBe(false); + }, 30000); + }); + }); + + describe("Organization management actions", () => { + describe("AWS Organizations", () => { + it("edits the organization name via the inline modal (PATCH)", async () => { + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ openWizard: false }); + await harness.waitForOrganizationRow(AWS_ORG_NAME); + + await harness.openEditNameFor(AWS_ORG_NAME); + + await harness.waitForEditNameModal(); + await harness.fillEditName("Renamed AWS Org"); + await harness.saveName(); + + await harness.waitForOrganizationRename(AWS_HIERARCHY_ORG_ID); + }, 30000); + + it("names the organization after its identifier when the rename is blank", async () => { + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ openWizard: false }); + await harness.waitForOrganizationRow(AWS_ORG_NAME); + + await harness.openEditNameFor(AWS_ORG_NAME); + await harness.waitForEditNameModal(); + await harness.fillEditName(""); + await harness.saveName(); + + await harness.waitForOrganizationRename(AWS_HIERARCHY_ORG_ID); + const body = await harness.lastRequestBody( + "PATCH", + `/organizations/${AWS_HIERARCHY_ORG_ID}`, + ); + // Same rule as creation: the action rejects an empty name, so the client + // substitutes the identifier. + expect(body?.data.attributes.name).toBe(AWS_HIERARCHY_ORG_EXTERNAL_ID); + }, 30000); + + it("re-enters the wizard at the authentication step to update credentials", async () => { + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ openWizard: false }); + await harness.waitForOrganizationRow(AWS_ORG_NAME); + + await harness.openUpdateCredentialsFor(AWS_ORG_NAME); + + await harness.waitForAuthenticationStep(); + expect(harness.hasAuthenticateButton()).toBe(true); + // Edit-credentials re-entry skips the details step, so Back is hidden. + expect(harness.hasBackButton()).toBe(false); + }, 30000); + + it("deletes an organization with a cascade warning and deletion-task polling", async () => { + const harness = new ProvidersPageHarness(awsHierarchyFixture()); + await harness.mount({ openWizard: false }); + await harness.waitForOrganizationRow(AWS_ORG_NAME); + + await harness.openDeleteFor(AWS_ORG_NAME); + + await harness.waitForDeleteConfirmation(); + expect(harness.hasDeleteWarning()).toBe(true); + expect(harness.hasCascadeWarning(3)).toBe(true); + + await harness.confirmDelete(); + + await harness.waitForOrganizationDelete(AWS_HIERARCHY_ORG_ID); + // Deletion is a 202 + task: nothing is reported until the UI has polled it. + await harness.waitForTaskPoll(); + }, 30000); + }); + + describe("GCP Organizations", () => { + it("deletes a GCP folder with kind-aware copy and deletion-task polling", async () => { + const harness = new ProvidersPageHarness(mixedHierarchyFixture()); + await harness.mount({ openWizard: false }); + await harness.waitForNodeGroup(GCP_FOLDER_NAME); + + // GCP container nodes are "folders", not "organizational units". + await harness.openDeleteFolderFor(GCP_FOLDER_NAME); + + await harness.waitForDeleteConfirmation(); + expect(harness.hasDeleteWarningFor("folder")).toBe(true); + + await harness.confirmDelete(); + + await harness.waitForNodeDelete(GCP_FOLDER_NODE_ID); + // The polled task completes when the per-provider deletions are dispatched, so + // the copy may report acceptance and never that the folder is gone. + await harness.waitForTaskPoll("del-task-"); + await harness.waitForDeletionAccepted(); + expect(harness.claimsDeletionFinished()).toBe(false); + }, 30000); + + it("reports a failed deletion task instead of a false success", async () => { + const harness = new ProvidersPageHarness( + mixedHierarchyFixture({ deletionTaskState: "failed" }), + ); + await harness.mount({ openWizard: false }); + await harness.waitForOrganizationRow(GCP_ORG_NAME); + + await harness.openDeleteFor(GCP_ORG_NAME); + + await harness.waitForDeleteConfirmation(); + expect(harness.hasCascadeWarning(2)).toBe(true); + + await harness.confirmDelete(); + + await harness.waitForDeletionFailure(); + }, 30000); + }); + + describe("Azure Organizations", () => { + it("deletes a management group with kind-aware copy and deletion-task polling", async () => { + const harness = new ProvidersPageHarness(mixedHierarchyFixture()); + await harness.mount({ openWizard: false }); + await harness.waitForNodeGroup(AZURE_HIERARCHY_GROUP_NAME); + + // Azure container nodes are "management groups", not OUs or folders. + await harness.openDeleteManagementGroupFor(AZURE_HIERARCHY_GROUP_NAME); + + await harness.waitForDeleteConfirmation(); + expect(harness.hasDeleteWarningFor("management group")).toBe(true); + + await harness.confirmDelete(); + + await harness.waitForNodeDelete(AZURE_GROUP_NODE_ID); + // The polled task completes when the per-provider deletions are dispatched, so + // the copy may report acceptance and never that the group is gone. + await harness.waitForTaskPoll("del-task-"); + await harness.waitForDeletionAccepted(); + expect(harness.claimsDeletionFinished()).toBe(false); + }, 30000); + + it("reports a failed deletion task instead of a false success", async () => { + const harness = new ProvidersPageHarness( + mixedHierarchyFixture({ deletionTaskState: "failed" }), + ); + await harness.mount({ openWizard: false }); + await harness.waitForOrganizationRow(AZURE_ORG_NAME); + + await harness.openDeleteFor(AZURE_ORG_NAME); + + await harness.waitForDeleteConfirmation(); + expect(harness.hasCascadeWarning(2)).toBe(true); + + await harness.confirmDelete(); + + await harness.waitForDeletionFailure(); + }, 30000); + }); + + describe("Across organization types", () => { + it("offers wizard re-entry to every organization type with a setup form", async () => { + const harness = new ProvidersPageHarness(mixedHierarchyFixture()); + await harness.mount({ openWizard: false }); + + await harness.waitForOrganizationRow("My GCP Organization"); + + // Every type with a setup form offers "Update Credentials". + const actions = await harness.actionLabelsFor("My GCP Organization"); + expect(actions).toContain("Edit Organization Name"); + expect(actions).toContain("Update Credentials"); + + const azureActions = await harness.actionLabelsFor(AZURE_ORG_NAME); + expect(azureActions).toContain("Update Credentials"); + + const awsActions = await harness.actionLabelsFor("My AWS Organization"); + expect(awsActions).toContain("Update Credentials"); + }, 30000); + }); + }); }); diff --git a/ui/auth.config.ts b/ui/auth.config.ts index eefc73b2a4..9d7544e371 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -54,6 +54,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = { manage_integrations: false, manage_billing: false, manage_alerts: false, + manage_lighthouse_ai_configuration: false, unlimited_visibility: false, }; diff --git a/ui/changelog.d/imported-provider-provenance.added.md b/ui/changelog.d/imported-provider-provenance.added.md new file mode 100644 index 0000000000..79999a976a --- /dev/null +++ b/ui/changelog.d/imported-provider-provenance.added.md @@ -0,0 +1 @@ +Prowler Cloud indicator for providers created via Import Findings alongside every connection status diff --git a/ui/changelog.d/provider-wizard-preconfigured-credential-links.added.md b/ui/changelog.d/provider-wizard-preconfigured-credential-links.added.md deleted file mode 100644 index 90ea9a436e..0000000000 --- a/ui/changelog.d/provider-wizard-preconfigured-credential-links.added.md +++ /dev/null @@ -1 +0,0 @@ -Surface pre-configured credential creation links in the add-provider wizard. Cloudflare exposes the User API Token template and an Account-Owned template pinned to the Cloudflare Account ID entered in the wizard, GitHub exposes the personal-repositories template and an organization-scanning template pinned to the identifier entered in the wizard diff --git a/ui/changelog.d/scan-auto-refresh-settlement.fixed.md b/ui/changelog.d/scan-auto-refresh-settlement.fixed.md new file mode 100644 index 0000000000..c87c5085ed --- /dev/null +++ b/ui/changelog.d/scan-auto-refresh-settlement.fixed.md @@ -0,0 +1 @@ +Scan auto-refresh no longer overlaps slow client refreshes and now signals when scan execution settles diff --git a/ui/changelog.d/scan-trial-sidebar-banner.added.md b/ui/changelog.d/scan-trial-sidebar-banner.added.md new file mode 100644 index 0000000000..9b853f2e48 --- /dev/null +++ b/ui/changelog.d/scan-trial-sidebar-banner.added.md @@ -0,0 +1 @@ +Display the default one-scan free trial and trial expiration in the existing sidebar banner diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 26e6e00585..3cda99780b 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -4,6 +4,7 @@ import { AlertTriangle } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { + loadFindingTriageDetail, loadLatestFindingTriageNote, updateFindingTriage, } from "@/actions/findings"; @@ -81,16 +82,17 @@ export const ClientAccordionContent = ({ }); const handleTriageUpdate = async (input: UpdateFindingTriageInput) => { - await updateFindingTriage(input); + const result = await updateFindingTriage(input); // Mutelist-shortcut statuses mute the finding server-side; refetch so the // list honors the muted filter, matching the resource drawer behavior. if (shouldRefreshAfterTriageUpdate(input)) { reload(); - return; + return result; } patchTriageUpdate(input); + return result; }; const renderDetails = () => { @@ -180,6 +182,7 @@ export const ClientAccordionContent = ({ openFindingId, onTriageUpdateAction: handleTriageUpdate, onTriageNoteLoadAction: loadLatestFindingTriageNote, + onTriageDetailLoadAction: loadFindingTriageDetail, })} data={expandedFindings} metadata={findings?.meta} diff --git a/ui/components/compliance/compliance-accordion/use-requirement-findings.test.ts b/ui/components/compliance/compliance-accordion/use-requirement-findings.test.ts index 3de5bb236c..5aa10e1e0f 100644 --- a/ui/components/compliance/compliance-accordion/use-requirement-findings.test.ts +++ b/ui/components/compliance/compliance-accordion/use-requirement-findings.test.ts @@ -301,19 +301,22 @@ describe("useRequirementFindings", () => { findingUid: "uid-1", triageId: "triage-1", notesCount: 0, - status: FINDING_TRIAGE_STATUS.REMEDIATING, + status: FINDING_TRIAGE_STATUS.RESOLVED, previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, isMuted: false, + manualPassEvidence: "Verified by the control owner.", }); }); // Then expect(result.current.expandedFindings[0]?.triage).toEqual( expect.objectContaining({ - status: FINDING_TRIAGE_STATUS.REMEDIATING, - label: "Remediating", + status: FINDING_TRIAGE_STATUS.RESOLVED, + label: "Resolved", + manualPassProvenance: "Manually verified", }), ); + expect(result.current.expandedFindings[0]?.attributes.status).toBe("PASS"); }); it("should ignore stale responses after the query changes", async () => { diff --git a/ui/components/compliance/compliance-custom-details/cmmc-details.tsx b/ui/components/compliance/compliance-custom-details/cmmc-details.tsx new file mode 100644 index 0000000000..4b452a88b5 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/cmmc-details.tsx @@ -0,0 +1,49 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +interface CMMCDetailsProps { + requirement: Requirement; +} + +export const CMMCCustomDetails = ({ requirement }: CMMCDetailsProps) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + {requirement.domain && ( + + )} + {requirement.level && ( + + )} + {requirement.source_requirement && ( + + )} + + + ); +}; diff --git a/ui/components/findings/table/column-finding-groups.test.tsx b/ui/components/findings/table/column-finding-groups.test.tsx index ca6e01bb7b..d871cbe0e2 100644 --- a/ui/components/findings/table/column-finding-groups.test.tsx +++ b/ui/components/findings/table/column-finding-groups.test.tsx @@ -314,6 +314,25 @@ describe("column-finding-groups — accessibility of check title cell", () => { expect(columnIds.at(-1)).toBe("actions"); }); + it("should not expose the manual-only Resolved path on group-level rows", () => { + // Given + const columns = getColumnFindingGroups({ + rowSelection: {}, + selectableRowCount: 1, + onDrillDown: vi.fn(), + }); + + // When + const columnIds = columns.map( + (column) => + (column as { id?: string; accessorKey?: string }).id ?? + (column as { id?: string; accessorKey?: string }).accessorKey, + ); + + // Then + expect(columnIds).not.toContain("triage"); + }); + it("should render the first provider icon with its provider name", () => { // Given renderFindingGroupTitleCell({ providers: ["iac"] }); diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index ce33c2a867..ab2f0d6070 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -7,9 +7,12 @@ import type { } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { isGroupedJiraDispatchEnabledMock } = vi.hoisted(() => ({ - isGroupedJiraDispatchEnabledMock: vi.fn(() => true), -})); +const { isCloudMock, isGroupedJiraDispatchEnabledMock, launchSkillMock } = + vi.hoisted(() => ({ + isCloudMock: vi.fn(() => false), + isGroupedJiraDispatchEnabledMock: vi.fn(() => true), + launchSkillMock: vi.fn(), + })); // CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. vi.mock("@/components/shadcn/custom/custom-link", () => ({ @@ -67,6 +70,19 @@ vi.mock("@/components/shadcn/dropdown", () => ({ {label} ), + DropdownMenuLabel: ({ children }: { children?: ReactNode }) => ( +
    {children}
    + ), + DropdownMenuSeparator: () =>
    , + DropdownMenuSub: ({ children }: { children: ReactNode }) => ( +
    {children}
    + ), + DropdownMenuSubContent: ({ children }: { children: ReactNode }) => ( +
    {children}
    + ), + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => ( + {children} + ), })); vi.mock("@/components/shadcn/info-field/info-field", () => ({ @@ -169,6 +185,19 @@ vi.mock("@/lib/deployment", () => ({ PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", })); +vi.mock("@/lib/shared/env", () => ({ + isCloud: isCloudMock, +})); + +vi.mock("./lighthouse-skills-launch", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useLighthouseSkillLaunch: () => launchSkillMock, + }; +}); + const notificationIndicatorMock = vi.fn((_props: unknown) => null); vi.mock("./notification-indicator", () => ({ @@ -247,10 +276,14 @@ function getColumnIds(columns: ReturnType) { function renderResourceActionsCell({ resource = makeResource(), + onSkillLaunchOpenDrawer, onTriageUpdateAction, onTriageNoteLoadAction, }: { resource?: FindingResourceRow; + onSkillLaunchOpenDrawer?: Parameters< + typeof getColumnFindingResources + >[0]["onSkillLaunchOpenDrawer"]; onTriageUpdateAction?: Parameters< typeof getColumnFindingResources >[0]["onTriageUpdateAction"]; @@ -261,6 +294,7 @@ function renderResourceActionsCell({ const columns = getColumnFindingResources({ rowSelection: {}, selectableRowCount: 1, + onSkillLaunchOpenDrawer, onTriageUpdateAction, onTriageNoteLoadAction, }); @@ -272,19 +306,48 @@ function renderResourceActionsCell({ throw new Error("actions column not found"); } const CellComponent = actionsColumn.cell as (props: { - row: { original: FindingResourceRow }; + row: { original: FindingResourceRow; index: number }; }) => ReactNode; - render(
    {CellComponent({ row: { original: resource } })}
    ); + render(
    {CellComponent({ row: { original: resource, index: 0 } })}
    ); } describe("column-finding-resources", () => { beforeEach(() => { vi.clearAllMocks(); + isCloudMock.mockReturnValue(false); isGroupedJiraDispatchEnabledMock.mockReturnValue(true); useJiraDispatchStore.getState().closeJiraDispatch(); }); + it("opens the finding drawer and launches a row skill with full context", async () => { + // Given + const user = userEvent.setup(); + const onSkillLaunchOpenDrawer = vi.fn(); + isCloudMock.mockReturnValue(true); + renderResourceActionsCell({ onSkillLaunchOpenDrawer }); + + // When + await user.click(screen.getByRole("button", { name: "Triage Decision" })); + + // Then + expect(onSkillLaunchOpenDrawer).toHaveBeenCalledWith(0); + expect(launchSkillMock).toHaveBeenCalledWith( + expect.objectContaining({ id: "triage-decision" }), + expect.objectContaining({ + kind: "finding", + findingId: "finding-1", + checkId: "s3_check", + providerUid: "123456789", + resourceUid: "arn:aws:s3:::my-bucket", + region: "us-east-1", + }), + ); + expect(onSkillLaunchOpenDrawer.mock.invocationCallOrder[0]).toBeLessThan( + launchSkillMock.mock.invocationCallOrder[0], + ); + }); + it("should render actions as the last visible column after Triage without Notes", () => { // Given const columns = getColumnFindingResources({ diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index cddf57f429..c5cc656ebb 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -20,10 +20,14 @@ import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-colu import { getFailingForLabel } from "@/lib/date-utils"; import { buildJiraActionLabel } from "@/lib/jira-dispatch-action"; import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection"; +import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions"; +import { isCloud } from "@/lib/shared/env"; import { FindingResourceRow } from "@/types"; import type { - FindingTriageLoadedNote, - FindingTriageSummary, + FindingTriageContext, + FindingTriageDetailLoadHandler, + FindingTriageNoteLoadHandler, + FindingTriageUpdateHandler, } from "@/types/findings-triage"; import { JIRA_DISPATCH_TARGET } from "@/types/integrations"; @@ -32,28 +36,60 @@ import { FindingNoteActionItem, FindingTriageStatusCell, } from "./finding-triage-cells"; -import type { FindingTriageUpdateHandler } from "./finding-triage-status-control"; import { FindingsSelectionContext } from "./findings-selection-context"; +import { + LighthouseSkillsRowButton, + LighthouseSkillsSubmenu, + useLighthousePromptLaunch, + useLighthouseSkillLaunch, +} from "./lighthouse-skills-launch"; import { type DeltaType, NotificationIndicator, } from "./notification-indicator"; +const buildFindingResourceTriageContext = ( + resource: FindingResourceRow, + findingTitle?: string, +): FindingTriageContext => ({ + title: findingTitle || resource.checkId, + resource: resource.resourceName, + provider: resource.providerAlias, + providerType: resource.providerType, +}); + +// One finding-context item per resource row, shared by the leading Skills +// pill and the ⋮ submenu so both launch with identical context. +const buildResourceFindingItem = (resource: FindingResourceRow) => + buildFindingResourceContext({ + findingId: resource.findingId, + checkId: resource.checkId, + severity: resource.severity, + status: resource.status, + providerUid: resource.providerUid, + resourceUid: resource.resourceUid, + region: resource.region, + }); + const ResourceRowActions = ({ row, findingTitle, + onSkillLaunchOpenDrawer, onTriageUpdateAction, onTriageNoteLoadAction, + onTriageDetailLoadAction, }: { row: Row; findingTitle?: string; + onSkillLaunchOpenDrawer?: (rowIndex: number) => void; onTriageUpdateAction?: FindingTriageUpdateHandler; - onTriageNoteLoadAction?: ( - triage: FindingTriageSummary, - ) => Promise; + onTriageNoteLoadAction?: FindingTriageNoteLoadHandler; + onTriageDetailLoadAction?: FindingTriageDetailLoadHandler; }) => { const resource = row.original; const canMute = canMuteFindingResource(resource); + const launchSkill = useLighthouseSkillLaunch(); + const launchPrompt = useLighthousePromptLaunch(); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const [resolvedIds, setResolvedIds] = useState([]); const [isResolving, setIsResolving] = useState(false); @@ -135,14 +171,13 @@ const ResourceRowActions = ({ + {isCloud() && ( + { + onSkillLaunchOpenDrawer?.(row.index); + launchSkill(skill, buildResourceFindingItem(resource)); + }} + onSubmitPrompt={(text) => { + onSkillLaunchOpenDrawer?.(row.index); + launchPrompt(text, buildResourceFindingItem(resource)); + }} + /> + )}
    @@ -174,18 +221,22 @@ interface GetColumnFindingResourcesOptions { rowSelection: RowSelectionState; selectableRowCount: number; findingTitle?: string; + // Skill launch (pill or ⋮ submenu) opens this row's finding drawer behind + // the chat tab, so the run and the finding share the side panel. + onSkillLaunchOpenDrawer?: (rowIndex: number) => void; onTriageUpdateAction?: FindingTriageUpdateHandler; - onTriageNoteLoadAction?: ( - triage: FindingTriageSummary, - ) => Promise; + onTriageNoteLoadAction?: FindingTriageNoteLoadHandler; + onTriageDetailLoadAction?: FindingTriageDetailLoadHandler; } export function getColumnFindingResources({ rowSelection, selectableRowCount, findingTitle, + onSkillLaunchOpenDrawer, onTriageUpdateAction, onTriageNoteLoadAction, + onTriageDetailLoadAction, }: GetColumnFindingResourcesOptions): ColumnDef[] { const selectedCount = Object.values(rowSelection).filter(Boolean).length; const isAllSelected = @@ -206,6 +257,7 @@ export function getColumnFindingResources({ return (
    + {/* Mirrors the row's indicator + arrow so checkboxes stay aligned */}
    ( -
    + // relative: paints above the cell's hover-extension pseudo-element, + // which would otherwise cover the in-flow checkbox and indicator. +
    - + {isCloud() ? ( + onSkillLaunchOpenDrawer?.(row.index)} + /> + ) : ( + + )} ), @@ -370,8 +436,10 @@ export function getColumnFindingResources({ ), enableSorting: false, diff --git a/ui/components/findings/table/column-standalone-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx index 7eacabd713..1da25fb653 100644 --- a/ui/components/findings/table/column-standalone-findings.tsx +++ b/ui/components/findings/table/column-standalone-findings.tsx @@ -18,14 +18,14 @@ import { getRegionFlag } from "@/lib/region-flags"; import { getOptionalText } from "@/lib/utils"; import { FindingProps, ProviderType } from "@/types"; import type { - FindingTriageLoadedNote, - FindingTriageSummary, + FindingTriageDetailLoadHandler, + FindingTriageNoteLoadHandler, + FindingTriageUpdateHandler, } from "@/types/findings-triage"; import { DataTableRowActions } from "./data-table-row-actions"; import { FindingDetailDrawer } from "./finding-detail-drawer"; import { FindingTriageStatusCell } from "./finding-triage-cells"; -import type { FindingTriageUpdateHandler } from "./finding-triage-status-control"; import { DeltaValues, NotificationIndicator } from "./notification-indicator"; import { ProviderIconCell } from "./provider-icon-cell"; @@ -33,9 +33,8 @@ interface GetStandaloneFindingColumnsOptions { includeUpdatedAt?: boolean; openFindingId?: string | null; onTriageUpdateAction?: FindingTriageUpdateHandler; - onTriageNoteLoadAction?: ( - triage: FindingTriageSummary, - ) => Promise; + onTriageNoteLoadAction?: FindingTriageNoteLoadHandler; + onTriageDetailLoadAction?: FindingTriageDetailLoadHandler; } const getFindingsData = (row: { original: FindingProps }) => { @@ -60,6 +59,15 @@ const getProviderData = ( return row.original.relationships?.provider?.attributes?.[field] || "-"; }; +const buildFindingContext = (row: { original: FindingProps }) => ({ + title: row.original.attributes.check_metadata.checktitle, + resource: getOptionalText(getResourceData(row, "name")), + provider: getOptionalText(getProviderData(row, "alias")), + providerType: getOptionalText(getProviderData(row, "provider")) as + | ProviderType + | undefined, +}); + function FindingTitleCell({ finding, defaultOpen = false, @@ -93,6 +101,7 @@ export function getStandaloneFindingColumns({ openFindingId = null, onTriageUpdateAction, onTriageNoteLoadAction, + onTriageDetailLoadAction, }: GetStandaloneFindingColumnsOptions = {}): ColumnDef[] { const columns: ColumnDef[] = [ { @@ -295,7 +304,9 @@ export function getStandaloneFindingColumns({ cell: ({ row }) => ( ), enableSorting: false, @@ -304,27 +315,15 @@ export function getStandaloneFindingColumns({ id: "actions", size: 56, header: () =>
    , - cell: ({ row }) => { - const resourceName = getResourceData(row, "name"); - const providerAlias = getProviderData(row, "alias"); - const providerType = getProviderData(row, "provider"); - - return ( - - ); - }, + cell: ({ row }) => ( + + ), enableSorting: false, }, ); diff --git a/ui/components/findings/table/data-table-row-actions.test.tsx b/ui/components/findings/table/data-table-row-actions.test.tsx index d4ac28b42c..daad634da2 100644 --- a/ui/components/findings/table/data-table-row-actions.test.tsx +++ b/ui/components/findings/table/data-table-row-actions.test.tsx @@ -15,9 +15,13 @@ import { } from "./data-table-row-actions"; import { FindingsSelectionContext } from "./findings-selection-context"; -const { MuteFindingsModalMock } = vi.hoisted(() => ({ - MuteFindingsModalMock: vi.fn((_props: unknown) => null), -})); +const { isCloudMock, launchSkillMock, MuteFindingsModalMock } = vi.hoisted( + () => ({ + isCloudMock: vi.fn(() => false), + launchSkillMock: vi.fn(), + MuteFindingsModalMock: vi.fn((_props: unknown) => null), + }), +); vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }), @@ -36,6 +40,19 @@ vi.mock("@/lib/deployment", () => ({ PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", })); +vi.mock("@/lib/shared/env", () => ({ + isCloud: isCloudMock, +})); + +vi.mock("./lighthouse-skills-launch", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useLighthouseSkillLaunch: () => launchSkillMock, + }; +}); + vi.mock("@/components/shadcn/dropdown", () => ({ ActionDropdown: ({ children }: { children: React.ReactNode }) => (
    {children}
    @@ -53,6 +70,19 @@ vi.mock("@/components/shadcn/dropdown", () => ({ {label} ), + DropdownMenuLabel: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
    + ), + DropdownMenuSeparator: () =>
    , + DropdownMenuSub: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + DropdownMenuSubContent: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + DropdownMenuSubTrigger: ({ children }: { children: React.ReactNode }) => ( + {children} + ), })); vi.mock("@/components/shadcn/spinner/spinner", () => ({ @@ -136,9 +166,57 @@ function makeFindingRow(overrides?: Partial) { describe("DataTableRowActions", () => { beforeEach(() => { vi.clearAllMocks(); + isCloudMock.mockReturnValue(false); useJiraDispatchStore.getState().closeJiraDispatch(); }); + it("launches a Lighthouse skill from the row submenu with finding context", async () => { + // Given + const user = userEvent.setup(); + isCloudMock.mockReturnValue(true); + render(); + + // When + await user.click(screen.getByRole("button", { name: "Triage Decision" })); + + // Then + expect(launchSkillMock).toHaveBeenCalledWith( + expect.objectContaining({ id: "triage-decision" }), + expect.objectContaining({ + kind: "finding", + findingId: "finding-1", + }), + ); + }); + + it("hides the Lighthouse skills submenu on finding group rows", () => { + // Group rows carry check ids, not finding UUIDs, so the finding-level + // skills (and their Jira/mute follow-up actions) must not launch there. + isCloudMock.mockReturnValue(true); + render( + , + ); + + expect(screen.queryByText("Lighthouse Skills")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Triage Decision" }), + ).not.toBeInTheDocument(); + }); + it("opens the mute modal immediately in preparing state for finding groups", async () => { // Given const deferred = deferredPromise(); diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index dd6cdfe704..3033003e57 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -15,19 +15,28 @@ import { Spinner } from "@/components/shadcn/spinner/spinner"; import { isFindingGroupMuted } from "@/lib/findings-groups"; import { buildJiraActionLabel } from "@/lib/jira-dispatch-action"; import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection"; +import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions"; +import { isCloud } from "@/lib/shared/env"; import { getOptionalText } from "@/lib/utils"; import type { - FindingTriageLoadedNote, + FindingTriageContext, + FindingTriageDetailLoadHandler, + FindingTriageNoteLoadHandler, FindingTriageSummary, + FindingTriageUpdateHandler, } from "@/types/findings-triage"; import { JIRA_DISPATCH_TARGET } from "@/types/integrations"; +import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; import type { ProviderType } from "@/types/providers"; import { canMuteFindingGroup } from "./finding-group-selection"; -import type { FindingTriageContext } from "./finding-note-modal"; import { FindingNoteActionItem } from "./finding-triage-cells"; -import type { FindingTriageUpdateHandler } from "./finding-triage-status-control"; import { FindingsSelectionContext } from "./findings-selection-context"; +import { + LighthouseSkillsSubmenu, + useLighthousePromptLaunch, + useLighthouseSkillLaunch, +} from "./lighthouse-skills-launch"; export interface FindingRowData { id: string; @@ -37,6 +46,8 @@ export interface FindingRowData { checktitle?: string; }; }; + severity?: string; + status?: string; triage?: FindingTriageSummary; relationships?: { resource?: { @@ -97,9 +108,8 @@ interface DataTableRowActionsProps { onMuteComplete?: (findingIds: string[]) => void; findingContext?: FindingTriageContext; onTriageUpdateAction?: FindingTriageUpdateHandler; - onTriageNoteLoadAction?: ( - triage: FindingTriageSummary, - ) => Promise; + onTriageNoteLoadAction?: FindingTriageNoteLoadHandler; + onTriageDetailLoadAction?: FindingTriageDetailLoadHandler; } export function DataTableRowActions({ @@ -108,6 +118,7 @@ export function DataTableRowActions({ findingContext, onTriageUpdateAction, onTriageNoteLoadAction, + onTriageDetailLoadAction, }: DataTableRowActionsProps) { const router = useRouter(); const finding = row.original; @@ -235,6 +246,19 @@ export function DataTableRowActions({ router.refresh(); }; + const launchSkill = useLighthouseSkillLaunch(); + const launchPrompt = useLighthousePromptLaunch(); + // Skills are finding-level only: group rows carry check ids, not finding + // UUIDs, so their menu never offers the Lighthouse entries (see below). + const buildSkillFindingItem = () => + buildFindingResourceContext({ findingId: finding.id }); + const handleLaunchSkill = (skill: LighthouseSkillDefinition) => { + launchSkill(skill, buildSkillFindingItem()); + }; + const handleSubmitPrompt = (text: string) => { + launchPrompt(text, buildSkillFindingItem()); + }; + return ( <> ({ findingContext={resolvedFindingContext} onTriageUpdateAction={onTriageUpdateAction} onTriageNoteLoadAction={onTriageNoteLoadAction} + onTriageDetailLoadAction={onTriageDetailLoadAction} /> )} ({ onSelect={handleMuteClick} /> + {isCloud() && !isGroup && ( + + )}
    diff --git a/ui/components/findings/table/finding-note-modal.test.tsx b/ui/components/findings/table/finding-note-modal.test.tsx index 7004678394..7a2d3338d0 100644 --- a/ui/components/findings/table/finding-note-modal.test.tsx +++ b/ui/components/findings/table/finding-note-modal.test.tsx @@ -1,7 +1,24 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { ReactNode } from "react"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { type ReactNode, useEffect } from "react"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +const { toastMock } = vi.hoisted(() => ({ + toastMock: vi.fn(), +})); + +vi.mock("@/components/shadcn", async (importOriginal) => ({ + ...(await importOriginal()), + useToast: () => ({ toast: toastMock }), +})); vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ ProviderTypeIcon: ({ type }: { type: string }) => ( @@ -21,20 +38,32 @@ vi.mock("@/components/shadcn/modal", () => ({ children, open, title, + onOpenAutoFocus, }: { children: ReactNode; open: boolean; title?: string; - }) => - open ? ( + onOpenAutoFocus?: (event: Event) => void; + }) => { + useEffect(() => { + if (open) { + onOpenAutoFocus?.(new Event("openAutoFocus")); + } + }, [onOpenAutoFocus, open]); + + return open ? (

    {title}

    {children}
    - ) : null, + ) : null; + }, })); +const originalTimezone = process.env.TZ; + beforeAll(() => { + process.env.TZ = "UTC"; Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", { configurable: true, value: vi.fn(() => false), @@ -49,19 +78,31 @@ beforeAll(() => { }); }); +afterAll(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } +}); + import { DOCS_URLS } from "@/lib/external-urls"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { FINDING_STATUS } from "@/types/components"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_STATUS, type FindingTriageDetail, - type UpdateFindingTriageInput, + type FindingTriageContext, + type FindingTriageModalStatus, + type FindingTriageUpdateHandler, } from "@/types/findings-triage"; import { + FINDING_NOTE_MODAL_MODE, FindingNoteModal, - type FindingTriageContext, + type FindingNoteModalMode, } from "./finding-note-modal"; function makeTriageDetail( @@ -81,11 +122,20 @@ function makeTriageDetail( noteId: "note-1", noteBody: "Existing investigation note", maxNoteLength: 500, + rawFindingStatus: FINDING_STATUS.FAIL, + manualPassActive: null, + manualPassEvidence: null, + manualPassCreatedByName: null, + manualPassCreatedAt: null, + manualPassExpiresAt: null, + manualPassDeactivatedAt: null, ...overrides, }; } afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); useCloudUpgradeStore.getState().closeCloudUpgrade(); }); @@ -98,11 +148,15 @@ function renderNoteModal({ resource: "production-bucket", provider: "production-account", }, + mode, + initialStatus, }: { triage?: FindingTriageDetail; - onTriageUpdateAction?: (input: UpdateFindingTriageInput) => void; + onTriageUpdateAction?: FindingTriageUpdateHandler; onOpenChange?: (open: boolean) => void; findingContext?: FindingTriageContext; + mode?: FindingNoteModalMode; + initialStatus?: FindingTriageModalStatus; } = {}) { render( , ); @@ -145,7 +201,9 @@ describe("FindingNoteModal", () => { // Then const dialog = screen.getByRole("dialog", { name: "Add Triage Note" }); expect(dialog).toBeInTheDocument(); - expect(within(dialog).getByText("S3 bucket allows public reads")); + expect( + within(dialog).getByText("S3 bucket allows public reads"), + ).toBeVisible(); expect( within(dialog).getByRole("combobox", { name: "Triage status" }), ).toHaveTextContent("Remediating"); @@ -255,7 +313,11 @@ describe("FindingNoteModal", () => { // Given const user = userEvent.setup(); const onOpenChange = vi.fn(); - const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail")); + const onTriageUpdateAction = vi + .fn() + .mockRejectedValue( + new Error("Manual pass evidence must describe the verification."), + ); renderNoteModal({ onOpenChange, onTriageUpdateAction }); // When @@ -265,12 +327,54 @@ describe("FindingNoteModal", () => { // Then expect( - await screen.findByText("Could not update the note. Please try again."), + await screen.findByText( + "Manual pass evidence must describe the verification.", + ), ).toBeVisible(); expect( screen.getByRole("dialog", { name: "Add Triage Note" }), ).toBeInTheDocument(); expect(onOpenChange).not.toHaveBeenCalledWith(false); + expect(toastMock).not.toHaveBeenCalled(); + }); + + it("should expose a pending manual pass submission and keep controls disabled", async () => { + // Given + const user = userEvent.setup(); + let resolveUpdate: () => void = () => {}; + const onTriageUpdateAction = vi.fn( + () => + new Promise((resolve) => { + resolveUpdate = resolve; + }), + ); + renderNoteModal({ + triage: makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.MANUAL, + }), + onTriageUpdateAction, + }); + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Resolved" })); + await user.type( + screen.getByLabelText("Manual pass evidence"), + "Verified by the control owner.", + ); + + // When + await user.click(screen.getByRole("button", { name: "Save" })); + + // Then + expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled(); + expect(screen.getByLabelText("Manual pass evidence")).toBeDisabled(); + expect( + screen.getByRole("combobox", { name: "Triage status" }), + ).toBeDisabled(); + + resolveUpdate(); + await waitFor(() => + expect(screen.queryByText("Saving...")).not.toBeInTheDocument(), + ); }); it("should lock the status picker for resolved findings while keeping the note editable", async () => { @@ -403,4 +507,393 @@ describe("FindingNoteModal", () => { }), ); }); + + it("should offer Resolved only for an authoritative MANUAL finding without duration controls", async () => { + // Given + const user = userEvent.setup(); + renderNoteModal({ + triage: makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.MANUAL, + }), + }); + + // When + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + + // Then + expect(screen.getByRole("option", { name: "Resolved" })).toBeVisible(); + + // When + await user.click(screen.getByRole("option", { name: "Resolved" })); + + // Then + expect(screen.getByLabelText("Manual pass evidence")).toHaveValue(""); + expect(screen.getByLabelText("Manual pass evidence")).toBeRequired(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("should label, explain, and focus required manual pass evidence", () => { + // Given / When + renderNoteModal({ + triage: makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.MANUAL, + }), + initialStatus: FINDING_TRIAGE_STATUS.RESOLVED, + }); + + // Then + const evidence = screen.getByRole("textbox", { + name: "Manual pass evidence", + }); + expect(evidence).toHaveFocus(); + expect(evidence).toHaveAccessibleDescription( + "Add a Triage Note explaining why this finding passes.", + ); + }); + + it("should not offer Resolved when the authoritative raw finding status is not MANUAL", async () => { + // Given + const user = userEvent.setup(); + renderNoteModal({ + triage: makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.FAIL, + }), + }); + + // When + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + + // Then + expect(screen.queryByRole("option", { name: "Resolved" })).toBeNull(); + }); + + it("should submit Resolved with fresh evidence instead of the editable note", async () => { + // Given + const user = userEvent.setup(); + const onTriageUpdateAction = vi.fn(); + renderNoteModal({ + triage: makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.MANUAL, + }), + onTriageUpdateAction, + }); + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Resolved" })); + + // When + await user.type( + screen.getByLabelText("Manual pass evidence"), + "The control owner verified this requirement.", + ); + await user.click(screen.getByRole("button", { name: "Save" })); + + // Then + expect(onTriageUpdateAction).toHaveBeenCalledWith({ + findingId: "finding-1", + findingUid: "prowler-finding-uid-1", + triageId: "triage-1", + notesCount: 1, + noteId: "note-1", + isMuted: false, + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence: "The control owner verified this requirement.", + }); + }); + + it("should clear manual pass evidence when the modal closes before reopening", async () => { + // Given + const user = userEvent.setup(); + const triage = makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.MANUAL, + }); + const onOpenChange = vi.fn(); + const { rerender } = render( + , + ); + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Resolved" })); + await user.type( + screen.getByLabelText("Manual pass evidence"), + "Evidence that must not leak.", + ); + + // When + await user.click(screen.getByRole("button", { name: "Cancel" })); + rerender( + , + ); + + // Then + expect(screen.getByLabelText("Note text")).toHaveValue( + "Existing investigation note", + ); + expect(screen.queryByLabelText("Manual pass evidence")).toBeNull(); + }); + + it("should reset manual pass evidence when an unchanged update closes", async () => { + // Given + const user = userEvent.setup(); + const triage = makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.MANUAL, + }); + const onOpenChange = vi.fn(); + const { rerender } = render( + , + ); + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Resolved" })); + await user.type( + screen.getByRole("textbox", { name: "Manual pass evidence" }), + "Evidence that must not leak.", + ); + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Under Review" })); + + // When + await user.click(screen.getByRole("button", { name: "Save" })); + rerender( + , + ); + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Resolved" })); + + // Then + expect( + screen.getByRole("textbox", { name: "Manual pass evidence" }), + ).toHaveValue(""); + }); + + it("should show one manual pass success toast with the authoritative mutation expiry", async () => { + // Given + const user = userEvent.setup(); + const triage = makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.MANUAL, + }); + const onTriageUpdateAction = vi.fn().mockResolvedValue({ + manualPassExpiresAt: "2026-10-28T12:00:00Z", + }); + renderNoteModal({ triage, onTriageUpdateAction }); + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Resolved" })); + await user.type( + screen.getByLabelText("Manual pass evidence"), + "The control owner verified this requirement.", + ); + + // When + await user.click(screen.getByRole("button", { name: "Save" })); + + // Then + await waitFor(() => expect(toastMock).toHaveBeenCalledTimes(1)); + expect(toastMock).toHaveBeenCalledWith({ + title: "Finding manually verified as Pass", + description: "Triage: Resolved · Valid until Oct 28, 2026", + }); + }); + + it("should omit an invalid manual pass expiry from the success toast", async () => { + // Given + const user = userEvent.setup(); + const onTriageUpdateAction = vi.fn().mockResolvedValue({ + manualPassExpiresAt: "invalid-expiry", + }); + renderNoteModal({ + triage: makeTriageDetail({ + rawFindingStatus: FINDING_STATUS.MANUAL, + }), + onTriageUpdateAction, + }); + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Resolved" })); + await user.type( + screen.getByRole("textbox", { name: "Manual pass evidence" }), + "The control owner verified this requirement.", + ); + + // When + await user.click(screen.getByRole("button", { name: "Save" })); + + // Then + await waitFor(() => expect(toastMock).toHaveBeenCalledTimes(1)); + expect(toastMock).toHaveBeenCalledWith({ + title: "Finding manually verified as Pass", + description: "Triage: Resolved", + }); + }); + + it("should not show the manual pass toast for an ordinary update", async () => { + // Given + const user = userEvent.setup(); + renderNoteModal({ onTriageUpdateAction: vi.fn() }); + const textarea = screen.getByLabelText("Note text"); + await user.clear(textarea); + await user.type(textarea, "Documented owner follow-up."); + + // When + await user.click(screen.getByRole("button", { name: "Save" })); + + // Then + await waitFor(() => + expect(screen.queryByText("Saving...")).not.toBeInTheDocument(), + ); + expect(toastMock).not.toHaveBeenCalled(); + }); + + it("should show existing manual pass provenance and preserve note editing", () => { + // Given / When + renderNoteModal({ + triage: makeTriageDetail({ + status: FINDING_TRIAGE_STATUS.RESOLVED, + label: "Resolved", + rawFindingStatus: FINDING_STATUS.MANUAL, + manualPassActive: true, + manualPassEvidence: "The active control evidence was reviewed.", + manualPassCreatedByName: "Alex Security", + manualPassCreatedAt: "2026-06-03T10:00:00Z", + manualPassExpiresAt: "2026-06-17T10:00:00Z", + }), + }); + + // Then + expect( + screen.getByText(/Manually verified by Alex Security/i), + ).toBeVisible(); + expect(screen.getByText("Jun 03, 2026")).toBeVisible(); + expect(screen.getByText("Jun 17, 2026")).toBeVisible(); + expect( + screen.getByText("The active control evidence was reviewed."), + ).toBeVisible(); + expect(screen.queryByText("Expired")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Note text")).toHaveValue( + "Existing investigation note", + ); + expect(screen.queryByLabelText("Manual pass evidence")).toBeNull(); + }); + + it("should show expired Manual Pass provenance separately from required renewal evidence", async () => { + // Given + const user = userEvent.setup(); + renderNoteModal({ + triage: makeTriageDetail({ + status: FINDING_TRIAGE_STATUS.OPEN, + label: "Open", + rawFindingStatus: FINDING_STATUS.MANUAL, + manualPassActive: false, + manualPassEvidence: "The control owner verified the requirement.", + manualPassCreatedByName: "Alex Security", + manualPassCreatedAt: "2026-06-03T10:00:00Z", + manualPassExpiresAt: "2026-06-17T10:00:00Z", + manualPassDeactivatedAt: null, + }), + }); + + // Then + expect( + screen.getByText(/Previous Manual Pass by Alex Security/i), + ).toBeVisible(); + expect(screen.getByText("Expired")).toBeVisible(); + expect( + screen.getByText("The control owner verified the requirement."), + ).toBeVisible(); + expect( + screen.queryByDisplayValue("The control owner verified the requirement."), + ).not.toBeInTheDocument(); + expect(screen.getByText("Jun 03, 2026")).toBeVisible(); + expect(screen.getByText("Jun 17, 2026")).toBeVisible(); + + // When + await user.click(screen.getByRole("combobox", { name: "Triage status" })); + await user.click(screen.getByRole("option", { name: "Resolved" })); + + // Then + expect(screen.getByLabelText("Manual pass evidence")).toHaveValue(""); + expect(screen.getByLabelText("Manual pass evidence")).toBeRequired(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect( + screen.getByText("The control owner verified the requirement."), + ).toBeVisible(); + }); + + it("should show expired provenance safely when prior evidence is missing", () => { + // Given / When + renderNoteModal({ + triage: makeTriageDetail({ + status: FINDING_TRIAGE_STATUS.OPEN, + label: "Open", + rawFindingStatus: FINDING_STATUS.MANUAL, + manualPassActive: false, + manualPassEvidence: null, + manualPassCreatedAt: "2026-06-03T10:00:00Z", + manualPassExpiresAt: "2026-06-17T10:00:00Z", + }), + }); + + // Then + expect(screen.getByText(/Previous Manual Pass/i)).toBeVisible(); + expect(screen.getByText("Expired")).toBeVisible(); + expect(screen.queryByText("Evidence")).not.toBeInTheDocument(); + }); + + it("should show inactive Manual Pass details without edit or submit controls", () => { + // Given / When + renderNoteModal({ + mode: FINDING_NOTE_MODAL_MODE.MANUAL_PASS_DETAILS, + triage: makeTriageDetail({ + status: FINDING_TRIAGE_STATUS.RESOLVED, + label: "Resolved", + rawFindingStatus: FINDING_STATUS.MANUAL, + manualPassActive: false, + manualPassEvidence: null, + manualPassCreatedByName: "Alex Security", + manualPassCreatedAt: "2026-06-03T10:00:00Z", + manualPassExpiresAt: "2026-06-17T10:00:00Z", + manualPassDeactivatedAt: "2026-06-10T10:00:00Z", + }), + }); + + // Then + const dialog = screen.getByRole("dialog", { name: "Manual Pass Details" }); + expect(within(dialog).getByText("Inactive")).toBeVisible(); + expect(within(dialog).getByText("Inactive on")).toBeVisible(); + expect(within(dialog).getByText("Jun 10, 2026")).toBeVisible(); + expect(within(dialog).queryByText("Evidence")).not.toBeInTheDocument(); + expect( + within(dialog).getByRole("combobox", { name: "Triage status" }), + ).toBeDisabled(); + expect( + within(dialog).queryByLabelText("Note text"), + ).not.toBeInTheDocument(); + expect( + within(dialog).queryByLabelText("Manual pass evidence"), + ).not.toBeInTheDocument(); + expect( + within(dialog).queryByRole("button", { name: "Save" }), + ).not.toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Close" })).toBeVisible(); + }); }); diff --git a/ui/components/findings/table/finding-note-modal.tsx b/ui/components/findings/table/finding-note-modal.tsx index 83e3c34031..168479d84d 100644 --- a/ui/components/findings/table/finding-note-modal.tsx +++ b/ui/components/findings/table/finding-note-modal.tsx @@ -10,50 +10,61 @@ import { Badge, Button, Textarea, + useToast, } from "@/components/shadcn"; +import { DateWithTime } from "@/components/shadcn/entities"; +import { Field, FieldLabel } from "@/components/shadcn/field/field"; import { Modal } from "@/components/shadcn/modal"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { formatLocalDate } from "@/lib/date-utils"; import { DOCS_URLS } from "@/lib/external-urls"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { FINDING_STATUS } from "@/types/components"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_ORIGIN, FINDING_TRIAGE_RESOLVED_LOCKED_COPY, FINDING_TRIAGE_STATUS, + MANUAL_PASS_PROVENANCE, + type FindingTriageContext, type FindingTriageDetail, - type FindingTriageStatus, + type FindingTriageModalStatus, + type FindingTriageUpdateHandler, getFindingTriageMuteInfoCopy, + isManualStatus, isMutelistShortcutStatus, isTriageStatusLocked, } from "@/types/findings-triage"; -import type { ProviderType } from "@/types/providers"; import { FindingTriageStatusControl, - type FindingTriageUpdateHandler, + MANUAL_PASS_NOTE_REQUIRED_COPY, } from "./finding-triage-status-control"; import { buildFindingTriageUpdateInput } from "./finding-triage-submit"; -export interface FindingTriageContext { - title: string; - resource?: string; - provider?: string; - providerType?: ProviderType; -} - interface FindingNoteModalProps { open: boolean; onOpenChange: (open: boolean) => void; triage: FindingTriageDetail; findingContext: FindingTriageContext; + mode?: FindingNoteModalMode; + initialStatus?: FindingTriageModalStatus; onTriageUpdateAction?: FindingTriageUpdateHandler; } +export const FINDING_NOTE_MODAL_MODE = { + EDIT: "edit", + MANUAL_PASS_DETAILS: "manual-pass-details", +} as const; + +export type FindingNoteModalMode = + (typeof FINDING_NOTE_MODAL_MODE)[keyof typeof FINDING_NOTE_MODAL_MODE]; + const REMEDIATING_INFO_COPY = "Once this finding is remediated, if in the following scan its status changes to Pass, it will be automatically changed to Resolved"; @@ -62,31 +73,63 @@ export function FindingNoteModal({ onOpenChange, triage, findingContext, + mode = FINDING_NOTE_MODAL_MODE.EDIT, + initialStatus, onTriageUpdateAction, }: FindingNoteModalProps) { const openCloudUpgrade = useCloudUpgradeStore( (state) => state.openCloudUpgrade, ); + const { toast } = useToast(); + const initialSelectedStatus = + initialStatus ?? + (triage.status === FINDING_TRIAGE_STATUS.RESOLVED || + isManualStatus(triage.status) + ? triage.status + : FINDING_TRIAGE_STATUS.OPEN); // Local state needed: modal edits are buffered until the user chooses Update. - const [selectedStatus, setSelectedStatus] = useState( - triage.status, - ); + const [selectedStatus, setSelectedStatus] = + useState(initialSelectedStatus); const [note, setNote] = useState(triage.noteBody); + const [manualPassEvidence, setManualPassEvidence] = useState(""); const [submitError, setSubmitError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const noteTextareaRef = useRef(null); + const isManualPassDetails = + mode === FINDING_NOTE_MODAL_MODE.MANUAL_PASS_DETAILS; + const canEdit = + !isManualPassDetails && + triage.canEdit && + Boolean(onTriageUpdateAction) && + !isSubmitting; + const isManualPassSelected = + selectedStatus === FINDING_TRIAGE_STATUS.RESOLVED && + triage.rawFindingStatus === FINDING_STATUS.MANUAL && + triage.status !== FINDING_TRIAGE_STATUS.RESOLVED; const canSubmit = - triage.canEdit && Boolean(onTriageUpdateAction) && !isSubmitting; + canEdit && (!isManualPassSelected || manualPassEvidence.trim().length > 0); const isCloudOnly = triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY; const shouldShowMutelistInfo = - canSubmit && + canEdit && !triage.isMuted && selectedStatus !== triage.status && isMutelistShortcutStatus(selectedStatus); const shouldShowRemediatingInfo = selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING; const isStatusLocked = isTriageStatusLocked(triage.status); + const shouldShowManualPassProvenance = + triage.manualPassCreatedAt !== null && triage.manualPassExpiresAt !== null; + const isPreviousManualPass = triage.manualPassActive === false; + const manualPassState = + triage.manualPassActive === true + ? "Active" + : isPreviousManualPass + ? triage.manualPassDeactivatedAt + ? "Inactive" + : "Expired" + : null; + const hasManualPassEvidence = Boolean(triage.manualPassEvidence?.trim()); // Opened from a dropdown item: move focus into the dialog on mount so Radix's // aria-hidden is not applied to the still-focused dropdown that opened it. const handleOpenAutoFocus = (event: Event) => { @@ -98,6 +141,16 @@ export function FindingNoteModal({ // Otherwise let Radix auto-focus the first control inside the dialog. }; + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setSelectedStatus(initialSelectedStatus); + setNote(triage.noteBody); + setManualPassEvidence(""); + setSubmitError(null); + } + onOpenChange(nextOpen); + }; + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -113,17 +166,36 @@ export function FindingNoteModal({ triage, selectedStatus, noteBody: note, + manualPassEvidence, }); if (!updateInput) { - onOpenChange(false); + handleOpenChange(false); return; } - await onTriageUpdateAction?.(updateInput); - onOpenChange(false); - } catch { - setSubmitError("Could not update the note. Please try again."); + const updateResult = await onTriageUpdateAction?.(updateInput); + + if (isManualPassSelected && updateResult?.manualPassExpiresAt) { + const formattedExpiry = formatLocalDate( + updateResult.manualPassExpiresAt, + ); + + toast({ + title: "Finding manually verified as Pass", + description: formattedExpiry + ? `Triage: Resolved · Valid until ${formattedExpiry}` + : "Triage: Resolved", + }); + } + + handleOpenChange(false); + } catch (error) { + setSubmitError( + error instanceof Error + ? error.message + : "Could not update the note. Please try again.", + ); } finally { setIsSubmitting(false); } @@ -132,29 +204,41 @@ export function FindingNoteModal({ return ( {/* min-w-0: the form is a grid item of DialogContent; without it, long unbreakable content (e.g. resource UIDs) widens the grid track past the modal instead of truncating. */}
    - + + + Triage documentation + + +
    + )}
    @@ -192,8 +276,15 @@ export function FindingNoteModal({
    @@ -207,6 +298,74 @@ export function FindingNoteModal({ )} + {shouldShowManualPassProvenance && ( + + +
    +
    + + {isPreviousManualPass + ? "Previous Manual Pass" + : MANUAL_PASS_PROVENANCE} + {triage.manualPassCreatedByName + ? ` by ${triage.manualPassCreatedByName}` + : ""} + . + + {manualPassState && ( + + {manualPassState} + + )} +
    + {hasManualPassEvidence && ( +
    + Evidence +

    + {triage.manualPassEvidence} +

    +
    + )} +
    +
    + Attested + +
    +
    + + {triage.manualPassDeactivatedAt + ? "Was valid until" + : isPreviousManualPass + ? "Expired on" + : "Valid until"} + + +
    + {triage.manualPassDeactivatedAt && ( +
    + Inactive on + +
    + )} +
    +
    +
    +
    + )} + {shouldShowMutelistInfo && ( @@ -227,62 +386,111 @@ export function FindingNoteModal({ )} -
    -