diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 35d5e95dee..a34dee5f85 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -25,6 +25,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 permissions: + actions: write contents: write pull-requests: write steps: @@ -33,6 +34,12 @@ jobs: with: egress-policy: audit + - name: Enable release freeze + env: + GH_TOKEN: ${{ github.token }} + run: | + gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}" + - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/release-freeze-gate.yml b/.github/workflows/release-freeze-gate.yml new file mode 100644 index 0000000000..dd060f0f9c --- /dev/null +++ b/.github/workflows/release-freeze-gate.yml @@ -0,0 +1,40 @@ +name: 'Tools: Release Freeze Gate' + +on: + pull_request: + branches: + - 'master' + types: + - opened + - synchronize + - reopened + - ready_for_review + merge_group: + branches: + - 'master' + types: + - checks_requested + workflow_dispatch: + +permissions: {} + +jobs: + release-freeze-gate: + name: release-freeze-gate + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check release freeze status + env: + RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }} + run: | + case "${RELEASE_FREEZE}" in + true|TRUE|True) + echo "::error::Release freeze is active. Merges to master are temporarily blocked." + echo "Set the RELEASE_FREEZE repository variable to false when the release is complete." + exit 1 + ;; + *) + echo "Release freeze is not active." + ;; + esac diff --git a/api/changelog.d/attack-paths-iam-privesc-cartesian.fixed.md b/api/changelog.d/attack-paths-iam-privesc-cartesian.fixed.md new file mode 100644 index 0000000000..61d75fbd39 --- /dev/null +++ b/api/changelog.d/attack-paths-iam-privesc-cartesian.fixed.md @@ -0,0 +1 @@ +Attack Paths IAM privilege-escalation queries no longer build an all-nodes × all-resource-items cartesian product, fixing runtime errors and timeouts on accounts with many IAM roles, users, or groups diff --git a/api/changelog.d/security-hub-connection-status.fixed.md b/api/changelog.d/security-hub-connection-status.fixed.md new file mode 100644 index 0000000000..08ac6edb45 --- /dev/null +++ b/api/changelog.d/security-hub-connection-status.fixed.md @@ -0,0 +1 @@ +AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index 60660a1632..6f44ce34f2 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -1927,12 +1927,20 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Find target users that the principal can create access keys for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate this statement's resource values into a list so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target users that the principal can create access keys for. + // Bind name/arn once so the `any` predicate reads locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -1975,16 +1983,24 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target users that the principal can rotate access keys for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate both statements' resource values into lists so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_user.name - OR target_user.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target users that the principal can rotate access keys for. + // Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2019,12 +2035,20 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Find target users that the principal can create login profiles for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate this statement's resource values into a list so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target users that the principal can create login profiles for. + // Bind name/arn once so the `any` predicate reads locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2097,12 +2121,20 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Find target users that the principal can update login profiles for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate this statement's resource values into a list so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target users that the principal can update login profiles for. + // Bind name/arn once so the `any` predicate reads locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2333,12 +2365,21 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( // Collapse the action-item fan-out: one row per (statement chain), not per matching action WITH DISTINCT aws, stmt, path_principal - // Find target roles whose trust policy this statement's resource can target - MATCH path_target = (aws)--(target_role:AWSRole) + // Pre-aggregate this statement's resource values into a list so the role + // match below is evaluated once per role (in-memory `any`) instead of + // building an (all-roles x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_role.name - OR target_role.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target roles whose trust policy this statement's resource can target. + // Bind the role's name/arn once so the `any` predicate reads them from a + // local variable instead of re-reading the property store per resource. + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2373,12 +2414,20 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Find target groups the principal can add users to - MATCH path_target = (aws)--(target_group:AWSGroup) + // Pre-aggregate this statement's resource values into a list so the group + // match below is evaluated once per group (in-memory `any`) instead of + // building an (all-groups x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_group.name - OR target_group.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target groups the principal can add users to. + // Bind name/arn once so the `any` predicate reads locals. + MATCH path_target = (aws)--(target_group:AWSGroup) + WITH path_principal, path_target, res_values, res_wildcard, + target_group.name AS gname, target_group.arn AS garn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS gname OR garn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2462,16 +2511,24 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target users the principal can attach policies to and create keys for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate both statements' resource values into lists so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_user.name - OR target_user.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target users the principal can attach policies to and create keys for. + // Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2596,16 +2653,24 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target users the principal can put policies on and create keys for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate both statements' resource values into lists so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_user.name - OR target_user.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target users the principal can put policies on and create keys for. + // Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2647,16 +2712,24 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target roles the principal can attach policies to and update trust policy for - MATCH path_target = (aws)--(target_role:AWSRole) + // Pre-aggregate both statements' resource values into lists so the role + // match below is evaluated once per role (in-memory `any`) instead of + // building an (all-roles x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_role.name - OR target_role.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_role.name - OR target_role.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target roles the principal can attach policies to and update trust + // policy for. Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2698,17 +2771,31 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target roles with customer-managed policies the principal can modify and update trust policy for - MATCH path_target = (aws)--(target_role:AWSRole) + // Pre-aggregate both statements' resource values into lists so the role + // and policy matches below are evaluated with an in-memory `any` instead + // of building an (all-roles x res2) x (policies x res) cartesian product. MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_role.name - OR target_role.arn CONTAINS res2.value - MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy) - WHERE target_policy.arn CONTAINS $provider_uid + WITH aws, stmt, path_principal, collect(DISTINCT res2.value) AS res2_values MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR target_policy.arn CONTAINS res.value + WITH aws, path_principal, res2_values, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res2_values, res_values, + ('*' IN res2_values) AS res2_wildcard, + ('*' IN res_values) AS res_wildcard + + // Find target roles with customer-managed policies the principal can + // modify and update trust policy for. Bind name/arn once so the `any` + // predicates read locals instead of re-reading the property store. + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, target_role, res_values, res_wildcard, + res2_values, res2_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res2_wildcard + OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy) + WITH path_principal, path_target, res_values, res_wildcard, + target_policy.arn AS parn + WHERE parn CONTAINS $provider_uid + AND (res_wildcard OR size([rv IN res_values WHERE parn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2750,16 +2837,24 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target roles the principal can put inline policies on and update trust policy for - MATCH path_target = (aws)--(target_role:AWSRole) + // Pre-aggregate both statements' resource values into lists so the role + // match below is evaluated once per role (in-memory `any`) instead of + // building an (all-roles x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_role.name - OR target_role.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_role.name - OR target_role.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target roles the principal can put inline policies on and update + // trust policy for. Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index c77ba22b7f..303d28c139 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -1,5 +1,6 @@ import os import time +from datetime import UTC, datetime from glob import glob from api.db_router import READ_REPLICA_ALIAS, MainRouter @@ -214,8 +215,10 @@ def get_security_hub_client_from_integration( for region in set(all_security_hub_regions): regions_status[region] = region in connection.enabled_regions - # Save regions information in the integration configuration + # Persist the successful connection check and regions information with rls_transaction(tenant_id, using=MainRouter.default_db): + integration.connected = True + integration.connection_last_checked_at = datetime.now(tz=UTC) integration.configuration["regions"] = regions_status integration.save() diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index bebb813feb..a95d02fa7f 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -1,3 +1,4 @@ +from datetime import UTC, datetime from unittest.mock import MagicMock, patch import pytest @@ -671,6 +672,8 @@ class TestSecurityHubIntegrationUploads: mock_integration = MagicMock() mock_integration.configuration = {"send_only_fails": True} mock_integration.credentials = {} # Empty credentials, use provider + mock_integration.connected = False + mock_integration.connection_last_checked_at = None # Mock tenant_id tenant_id = "550e8400-e29b-41d4-a716-446655440000" # Valid UUID @@ -723,12 +726,22 @@ class TestSecurityHubIntegrationUploads: # Configure the test_connection to return our mock_connection mock_security_hub_class.test_connection = mock_test_connection + checked_at_before = datetime.now(tz=UTC) connected, security_hub = get_security_hub_client_from_integration( mock_integration, tenant_id, mock_findings ) + checked_at_after = datetime.now(tz=UTC) assert connected is True assert security_hub == mock_security_hub + assert mock_integration.connected is True + assert mock_integration.connection_last_checked_at.tzinfo is UTC + assert ( + checked_at_before + <= mock_integration.connection_last_checked_at + <= checked_at_after + ) + mock_integration.save.assert_called_once() # Verify SecurityHub was called once to create the client assert mock_security_hub_class.call_count == 1 diff --git a/docs/developer-guide/attack-paths-queries.mdx b/docs/developer-guide/attack-paths-queries.mdx new file mode 100644 index 0000000000..2b7f69543d --- /dev/null +++ b/docs/developer-guide/attack-paths-queries.mdx @@ -0,0 +1,467 @@ +--- +title: "Attack Paths Queries" +--- + +This guide explains how to write and maintain Prowler Attack Paths queries: the read-only openCypher queries that traverse the Cartography-ingested cloud graph to detect privilege escalation chains, network exposure, and other graph-shaped security risks. + + +**New to Attack Paths?** Start with the user documentation: +- [Attack Paths](/user-guide/tutorials/prowler-app-attack-paths) - What Attack Paths detects, how to run built-in queries, and how to explore the resulting graph. +- [Writing Custom openCypher Queries](/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries) - Run ad-hoc read-only queries from the Prowler App. + + +## Introduction + +Attack Paths queries run against a property graph populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework, and enriched with Prowler findings. Every query is read-only openCypher (Version 9) so it runs on both the Neo4j and Amazon Neptune sinks. + +Two categories of query exist, each with a different isolation model: + +| | Predefined queries | Custom queries | +| ------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------- | +| Where they live | `api/src/backend/api/attack_paths/queries/{provider}.py` | User-supplied through the custom query API endpoint | +| Provider isolation | `AWSAccount {id: $provider_uid}` anchor plus path connectivity | Automatic `_Provider_{uuid}` label injection by `cypher_sanitizer.py` | +| What to write | Chain every `MATCH` from the `aws` variable | Plain Cypher, no isolation boilerplate | +| Internal labels | Never use | Never use (system-injected) | + +For **predefined queries**, every node must be reachable from the `AWSAccount` root through graph traversal. That reachability is the isolation boundary. + +For **custom queries**, the runner injects a `_Provider_{uuid}` label into every node pattern, and a post-query filter handles edge cases, so query authors write natural Cypher without isolation boilerplate. + +The rest of this guide focuses on predefined queries, though the graph model, list-property handling, and compatibility rules apply to both. + +## The Graph Model + +### Cartography Schema + +Node labels, relationship types, and properties follow the upstream Cartography schema for each provider. Do not guess them, fetch the schema for the pinned Cartography version: + +```bash +grep cartography api/pyproject.toml +``` + +Then read the schema for that exact tag: + +```text +# Git pin (prowler-cloud/cartography@): +https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags//docs/root/modules/{provider}/schema.md + +# PyPI pin (cartography==): +https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags//docs/root/modules/{provider}/schema.md +``` + +The public schema reference for AWS is available at [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html). + +### Prowler-Specific Additions + +The Prowler sync task enriches the Cartography graph with the following labels and relationships. These are not part of the upstream schema: + +| Label / Relationship | Description | +| ---------------------- | ----------------------------------------------------------- | +| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) | +| `Internet` | Internet sentinel node used to model public exposure | +| `CAN_ACCESS` | `(Internet)-[:CAN_ACCESS]->(resource)` exposure edge | +| `HAS_FINDING` | `(resource)-[:HAS_FINDING]->(:ProwlerFinding)` finding link | +| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | +| `STS_ASSUMEROLE_ALLOW` | Principal can assume a role | + +### Internal Isolation Labels + +The sync layer also adds internal labels used only for tenant and provider isolation: `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*`. These must never appear in query text, predefined or custom. The runner applies isolation automatically. + +## Query Structure + +### Provider Scoping Parameter + +| Parameter | Property | Used on | Purpose | +| --------------- | -------- | ------------ | -------------------------------------- | +| `$provider_uid` | `id` | `AWSAccount` | Scopes the query to a specific account | + +The runner binds `$provider_uid` automatically. Every other node is isolated by path connectivity from the `AWSAccount` anchor. + +### Imports + +```python +from api.attack_paths.queries.types import ( + AttackPathsQueryAttribution, + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) +from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL +``` + +Always reference `PROWLER_FINDING_LABEL` through f-string interpolation, never hardcode `"ProwlerFinding"`. + +### Definition Fields + +- **id**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`. +- **name**: short, human-friendly label. Sourced queries append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. +- **short_description**: one sentence, no technical permissions. +- **description**: full technical explanation, plain text. +- **provider**: `aws`, `azure`, `gcp`, `kubernetes`, or `github`. +- **cypher**: f-string Cypher body. Literal `{` and `}` are escaped as `{{` and `}}`. +- **parameters**: `parameters=[]` when the query takes no input. +- **attribution**: optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `link` uses the lowercase ID. + +Append the constant to the `{PROVIDER}_QUERIES` list at the bottom of the provider file. + +## The Predefined Query Template + +The canonical shape combines a principal walk, an optional target walk, deduplicated nodes, and a typed finding overlay: + +```python +AWS_QUERY_NAME = AttackPathsQueryDefinition( + id="aws-kebab-case-name", + name="Label (REFERENCE_ID)", + short_description="One sentence.", + description="Full technical explanation.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - REFERENCE_ID - permission", + link="https://pathfinding.cloud/paths/reference_id_lowercase", + ), + provider="aws", + cypher=f""" + // Find principals with the source permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['permission_lowercase', 'service:*'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal + + // Pre-aggregate the statement's resource values (see "Avoiding Cartesian Products") + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Match each target once against the in-memory resource list + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) +``` + +Key points: + +- The principal walk types the `POLICY` and `STATEMENT` hops. Both are low-fan-out (each principal has a handful of policies; each policy a handful of statements), so the typed edge lets the planner cost a cheap inline filter. +- The `(aws)--` hub hops stay anonymous. `AWSAccount` is a high-degree node that fans out to every principal, role, policy, and resource in the account; typing those edges forces the planner to enumerate from the hub and collapses performance on multi-tenant Neptune. +- Other relationship types appear only where the file's existing queries already use one (`TRUSTS_AWS_PRINCIPAL`, `STS_ASSUMEROLE_ALLOW`, `MEMBER_AWS_GROUP`, `HAS_EXECUTION_ROLE`). +- The finding probe is typed `:HAS_FINDING` and left undirected. The type lets Neptune apply an inline edge filter; the missing direction matches the convention of the rest of the file. +- Collapse duplicate rows after each permission gate with `WITH DISTINCT`, carrying only the variables needed by later clauses. +- The `RETURN` shape `paths, dpf, dpfr` is the contract the serializer and visualizer depend on. Do not change it. + +## Avoiding Cartesian Products + +The most common performance defect in Attack Paths queries is a Cartesian product between a target set and a policy statement's resource items. When the two are written as independent `MATCH` clauses, the planner pairs every target with every resource item before any filter runs. On accounts with many IAM principals, that multiplies into hundreds of thousands of rows and the query errors or times out. + +### The Pattern That Causes It + +```cypher +// One row per (target_role x resource_item): a Cartesian product +MATCH path_target = (aws)--(target_role:AWSRole) +MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) +WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value +``` + +The two `MATCH` clauses share no relationship, so the engine enumerates all targets multiplied by all resource items, applies a non-indexable `CONTAINS` to each pair, then expands the finding overlay for every surviving row. Cost grows with `targets × resources`, and a second constrained statement (`stmt2`) multiplies it again. + +### The Pattern That Avoids It + +Collect the statement's resource values into a list once, then match each target a single time against that in-memory list: + +```cypher +// Pre-aggregate the statement's resource values into a list +MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) +WITH aws, path_principal, collect(DISTINCT res.value) AS res_values +WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + +// Match each target once; bind name/arn to locals so the predicate reads them once +MATCH path_target = (aws)--(target_role:AWSRole) +WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn +WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 +``` + +Cost now grows with `targets + resources` (linear) rather than `targets × resources`. The rewrite is a pure algebraic identity: the result set is unchanged. + +Guidelines: + +- **Aggregate resources before matching targets, not after.** `collect(DISTINCT res.value)` reduces the resource items to a single list per statement. +- **Short-circuit the wildcard grant** with `('*' IN res_values)`. When a statement grants `*`, every target matches, so the list scan is skipped entirely. +- **Bind `target.name` and `target.arn` to local variables** in a `WITH` before the predicate. The list comprehension then reads each once per target instead of re-reading the property store once per resource value. +- **Use `size([... ]) > 0`, not `any(...)`.** The `any()`, `all()`, and `none()` predicate functions are not part of the openCypher specification and fail on Amazon Neptune. See [openCypher Compatibility](#opencypher-compatibility). +- **For two-statement queries**, aggregate each statement's resources into its own list (`res_values`, `res2_values`) and combine the two `size([... ]) > 0` checks with `AND`. + +Every IAM privilege escalation query in `aws.py` uses this pattern. The lateral-movement variants that constrain the target with a relationship (`STS_ASSUMEROLE_ALLOW`, `TRUSTS_AWS_PRINCIPAL`) already limit the target set before the resource filter, which keeps them efficient without further aggregation. + +## Privilege Escalation Sub-Patterns + +Four `path_target` shapes cover the common escalation types. Each shares the canonical template's `path_principal`, the resource pre-aggregation, the deduplication tail, and the `RETURN`; only the `path_target` `MATCH` and its resource predicate differ. + +| Sub-pattern | Target | `path_target` shape | Example | +| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | ------- | +| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 | +| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 | +| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 | +| PassRole plus service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: '{service}.amazonaws.com'})` | EC2-001 | + +**Multi-permission queries** (for example PassRole plus a service-create action) add permission gates before `path_target`. Reuse the per-query counter for new variables (`act2`, `policy2`, `stmt2`) and collapse rows after each gate: + +```cypher +MATCH (principal)-[:POLICY]->(policy2:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) +WHERE toLower(act2.value) IN ['service:*', 'service:createsomething'] + OR act2.value = '*' +WITH DISTINCT aws, principal, stmt, stmt2, path_principal +``` + +When a permission is an existence-only gate whose statement resource is not checked later, keep the policy and statement anonymous and carry only the variables still needed: + +```cypher +MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {effect: 'Allow'})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) +WHERE toLower(act3.value) IN ['service:*', 'service:othersomething'] + OR act3.value = '*' +WITH DISTINCT aws, principal, stmt, path_principal +``` + +## Network Exposure Pattern + +The Internet node is reached through `CAN_ACCESS` from an already-scoped resource, never as a standalone lookup: + +```python +cypher=f""" + // Resource scoped through the account anchor + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance) + WHERE resource.exposed_internet = true + + // Internet node reached through path connectivity from the resource + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, + internet, can_access +""" +``` + +The `CAN_ACCESS` edge stays typed and directed (`-[:CAN_ACCESS]->`); that is its canonical sync-time orientation. Network-exposure queries extend the `RETURN` contract with `internet, can_access`. + +## Working with List-Typed Properties + +Some Cartography node properties carry a list of values: `AWSPolicyStatement.action`, `AWSPolicyStatement.resource`, `AWSPolicyStatement.notaction`, `AWSPolicyStatement.notresource`, `KMSKey.encryption_algorithms`, `CloudFrontDistribution.aliases`, the container-definition lists on `ECSContainerDefinition`, and many others. The graph models each such property as a set of child item nodes connected to the parent by a typed edge. Queries reach the values by traversing the edge; the parent does not carry the list as a single field. + +### Naming Convention + +For a list-typed parent property the sink stores: + +- **Child label**: `Item`. Example: `AWSPolicyStatement.resource` becomes `AWSPolicyStatementResourceItem`. +- **Edge type**: `HAS_`. Example: `resource` becomes `HAS_RESOURCE`. +- **Child property**: `value`, a single scalar string per list element. For list-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) the child carries the original dict keys as named fields per the catalog's `field_map`. + +### Variable Naming for Child-Item Matches + +`aws.py` uses a per-query counter for each `HAS_*` traversal so chained matches stay unambiguous. The counter resets at the top of every query. + +| Edge | First | Second | Third | +| ----------------- | ------ | ------- | ------- | +| `HAS_ACTION` | `act` | `act2` | `act3` | +| `HAS_RESOURCE` | `res` | `res2` | `res3` | +| `HAS_NOTACTION` | `nact` | `nact2` | `nact3` | +| `HAS_NOTRESOURCE` | `nres` | `nres2` | `nres3` | + +### Matching an Action + +To find statements that grant `iam:PassRole`, `iam:*`, or `*`, traverse the `HAS_ACTION` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`: + +```cypher +MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) +WHERE toLower(act.value) IN ['iam:passrole', 'iam:*'] + OR act.value = '*' +``` + +The literal-action list is case-folded with `toLower(act.value)` because IAM authors mix case (`iam:PassRole`, `iam:passrole`); the `*` wildcard never lower-cases. + +### Matching a Resource Against a Target + +To find statements whose resource can target a specific node, pre-aggregate the resource values and test the target against the list once (see [Avoiding Cartesian Products](#avoiding-cartesian-products)): + +```cypher +MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) +WITH aws, path_principal, collect(DISTINCT res.value) AS res_values +WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + +MATCH path_target = (aws)--(target_role:AWSRole) +WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn +WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 +``` + +Three predicates cover the resource cases: full wildcard (`*`), a pattern containing the target name (`arn:aws:iam::*:role/admin*`), and a pattern that is a prefix or component of the actual ARN. + +### Every-Item and Any-Item Predicates on a Custom Query + +Custom queries can express list predicates directly with pattern comprehensions. To check whether *every* item satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached: + +```cypher +MATCH (stmt:AWSPolicyStatement) +WHERE size([ + (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) + WHERE NOT toLower(a.value) STARTS WITH 's3:' + | a + ]) = 0 + AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0 +RETURN stmt +LIMIT 25 +``` + +To return the list of values directly, collect them from the child items: + +```cypher +MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) +OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) +RETURN stmt, collect(a.value) AS actions +LIMIT 25 +``` + +### Catalog of List Properties + +The provider catalog lives in `api/src/backend/tasks/jobs/attack_paths/provider_config.py` (`AWS_NORMALIZED_LISTS`). Beyond policy statements it includes KMS algorithms, ECS container-definition lists (`entry_point`, `command`, `links`, `dns_servers`, and others), CloudFront aliases, Inspector finding URL and vulnerability lists, and RDS event-subscription categories. To query a list property that is not in the catalog, add an entry there first so the sync layer materializes it. Properties absent from the catalog are serialized to a comma-delimited string and emit a one-time warning during sync. + +## Working with JSON-Encoded Properties + +Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. To keep the schema portable across graph backends, object-typed properties are stored as JSON-encoded strings: + +``` +'{"StringEquals":{"aws:SourceAccount":"123456789012"}}' +``` + +No JSON parser is available at query time, so use `CONTAINS` for substring checks against keys or known values: + +```cypher +MATCH (stmt:AWSPolicyStatement) +WHERE stmt.effect = 'Allow' + AND stmt.condition CONTAINS '"aws:SourceAccount"' +RETURN stmt +LIMIT 25 +``` + +When a query needs to inspect the structured members of a condition (for example, to evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values. + +## openCypher Compatibility + +Queries must run on both Neo4j and Amazon Neptune. Neptune implements a subset of Cypher, so several convenient constructs are unavailable. Avoid the following: + +| Feature | Use instead | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph | +| Neptune extensions | Standard openCypher | +| `any(x IN list ...)` | `size([x IN list WHERE pred]) > 0` | +| `all(x IN list ...)` | `size([x IN list WHERE pred]) = size(list)` | +| `none(x IN list ...)` | `size([x IN list WHERE pred]) = 0` | +| `reduce()` | `UNWIND` plus `collect()` | +| `FOREACH` | `WITH` plus `UNWIND` plus `SET` | +| Regex `=~` | `toLower()` plus exact match, or `STARTS WITH` / `CONTAINS` | +| `CALL () { UNION }` | Multi-label `OR` in `WHERE` | +| Carried value plus aggregate expression | Project the aggregate first (`WITH principal_paths, collect(...) AS target_paths`), then combine lists in the next `WITH` | +| `EXISTS { MATCH (pattern) WHERE pred }` | Standalone `MATCH (pattern)` plus `WHERE pred`; precede the downstream `collect(path...)` with `WITH DISTINCT ` to dedupe the joins | + +The carried-value-plus-aggregate rule is worth calling out because it is easy to hit. Neo4j 5.x rejects an expression that concatenates a carried list variable with an aggregate in the same projection: + +```cypher +// Rejected: "Aggregation column contains implicit grouping expressions" +WITH principal_paths + collect(DISTINCT path_target) AS paths +``` + +Split it into two `WITH` clauses so the aggregation resolves before the concatenation: + +```cypher +WITH principal_paths, collect(DISTINCT path_target) AS target_paths +WITH principal_paths + target_paths AS paths +``` + +For list-typed properties in the catalog (action, resource, and so on), traverse the `HAS_*` edges to the child item nodes rather than reading a single field; `split(...)` and comma-string predicates do not apply. + +## Best Practices + +1. **Chain every MATCH from the account anchor.** An unanchored `MATCH (role:AWSRole)` returns roles from every provider in the graph; `MATCH (aws)--(role:AWSRole)` is scoped. A second-permission `MATCH` such as `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account subgraph. +2. **Pre-aggregate resource lists before matching targets** to avoid Cartesian products (see [Avoiding Cartesian Products](#avoiding-cartesian-products)). +3. **Type the finding probe.** Always `OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})`. The type lets Neptune apply an inline edge filter; an untyped probe scans every incident edge of high-degree nodes. +4. **Comment each MATCH.** One inline `// ...` line per clause explaining its role. +5. **Never use internal labels.** `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*` are system isolation labels and must not appear in query text. +6. **Reach the Internet node through path connectivity** with `(internet:Internet)-[:CAN_ACCESS]->(resource)`, never as a standalone match. +7. **Preserve the RETURN contract.** `paths, dpf, dpfr` for the standard shape; add `internet, can_access` for network-exposure queries. The serializer and visualizer depend on these names. + +## Naming Conventions + +- **ID**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`. +- **Constant**: `UPPER_SNAKE_CASE` `{PROVIDER}_{CATEGORY}_{DESCRIPTION}`, e.g. `AWS_EC2_PRIVESC_PASSROLE_IAM`. + +## Creating a New Query + +New queries come from one of two input sources: a [pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) research ID (for example `ECS-001`, `GLUE-001`) or a natural-language description from the requester. The aggregated `paths.json` is too large to fetch whole; query a single path by ID: + +```bash +# Fetch a single path by ID +curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | jq '.[] | select(.id == "ecs-002")' + +# List all path IDs and names +curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | jq -r '.[] | "\(.id): \(.name)"' +``` + +Then follow these steps: + +1. **Read the queries module first** to match the existing style: + + ```text + api/src/backend/api/attack_paths/queries/ + ├── __init__.py + ├── types.py # dataclass definitions + ├── registry.py + └── {provider}.py + ``` + +2. **Fetch the Cartography schema for the pinned version.** Do not guess labels, properties, or relationships. See [The Graph Model](#the-graph-model). + +3. **Build the query** from the canonical template plus the appropriate sub-pattern (privilege escalation or network exposure). Pre-aggregate resource lists, traverse `HAS_*` edges for list-typed properties, and keep the `RETURN` contract. + +4. **Register** the constant in the `{PROVIDER}_QUERIES` list at the bottom of the provider file. + +5. **Verify compatibility** against the [openCypher Compatibility](#opencypher-compatibility) rules, and confirm the query parses and runs on Neo4j before it reaches Neptune. + + +AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan with the `prowler_get_attack_paths_cartography_schema` tool, which guarantees that generated queries match the schema version pinned by the running Prowler release. + + +## Reference + +- **pathfinding.cloud**: [github.com/DataDog/pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) (use `curl | jq`; the aggregated `paths.json` is too large for a single fetch). +- **Cartography AWS schema**: [cartography-cncf.github.io/cartography/modules/aws/schema.html](https://cartography-cncf.github.io/cartography/modules/aws/schema.html). +- **Neptune openCypher compliance**: [docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html](https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html). +- **Neptune openCypher rewrites**: [docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html](https://docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html). +- **openCypher specification**: [github.com/opencypher/openCypher](https://github.com/opencypher/openCypher). diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx index 08c38ee17c..2ad65f50c3 100644 --- a/docs/developer-guide/introduction.mdx +++ b/docs/developer-guide/introduction.mdx @@ -45,6 +45,9 @@ Prowler is constantly evolving. Contributions to checks, services, or integratio Prowler can work with other tools and platforms through integrations. + + Want to detect new privilege escalation or exposure patterns? Contribute read-only openCypher queries that traverse the cloud graph. + Propose brand-new features or enhancements to existing ones, or help implement community-requested improvements. diff --git a/docs/docs.json b/docs/docs.json index d3e1eb420d..b5bc672f1b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -153,6 +153,13 @@ "user-guide/tutorials/prowler-app-rbac" ] }, + { + "group": "Attack Paths", + "pages": [ + "user-guide/tutorials/prowler-app-attack-paths", + "user-guide/tutorials/prowler-app-attack-paths-active-queries" + ] + }, { "group": "Compliance", "pages": [ @@ -165,7 +172,6 @@ "group": "Findings", "pages": [ "user-guide/tutorials/prowler-alerts", - "user-guide/tutorials/prowler-app-attack-paths", "user-guide/tutorials/prowler-app-finding-groups", "user-guide/tutorials/prowler-app-findings-triage" ] @@ -495,7 +501,8 @@ "developer-guide/mcp-server", "developer-guide/ai-skills", "developer-guide/prowler-studio", - "developer-guide/server-sent-events" + "developer-guide/server-sent-events", + "developer-guide/attack-paths-queries" ] }, { diff --git a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index acb50c9e81..68abeaf2ae 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool |----------|------------|------------------------| | Prowler Hub | 10 tools | No | | Prowler Documentation | 2 tools | No | -| Prowler Cloud, Private Cloud & Local Server | 32 tools | Yes | +| Prowler Cloud, Private Cloud & Local Server | 39 tools | Yes | ## Tool Naming Convention @@ -105,6 +105,23 @@ Tools for viewing compliance status and framework details across all cloud provi - **`prowler_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework - **`prowler_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs +### User Management + +Tools for viewing the users in your tenant and identifying the authenticated user. + +- **`prowler_list_users`** - List the users in the tenant with their names and emails +- **`prowler_get_user`** - Get detailed information about a specific user by ID, including join date and role/membership IDs +- **`prowler_get_current_user`** - Identify which user the current credentials authenticate as + +### Role Management + +Tools for browsing RBAC roles and managing the role assigned to a user. A user holds exactly one role, so setting a role replaces the one they held before. + +- **`prowler_list_roles`** - List the roles defined in the tenant with their permission scope +- **`prowler_get_role`** - Get detailed information about a specific role by ID, including granted capabilities, visibility scope, assigned users, and provider groups +- **`prowler_get_user_roles`** - List the roles assigned to a specific user, with the capabilities each role grants +- **`prowler_set_user_role`** - Set the role a user holds, replacing the role they had before (idempotent) + ## Prowler Hub Tools Access Prowler's security check catalog and compliance frameworks. **No authentication required.** diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index a30c4e5488..e31b13f12f 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -50,6 +50,7 @@ Full access to your Prowler deployment — Prowler Cloud, Prowler Private Cloud, - **Resource Inventory**: Search and view detailed information about your audited resources - **Muting Management**: Create and manage muting lists/rules to suppress non-relevant findings - **Attack Paths Analysis**: Analyze privilege escalation chains and security misconfigurations through graph-based analysis of cloud resource relationships +- **User & Role Management**: List the users in your tenant, identify the authenticated user, browse RBAC roles, and set the role a user holds ### 2. Prowler Hub diff --git a/docs/images/add-provider.png b/docs/images/add-provider.png index 4e986e3f1a..f0e78930e8 100644 Binary files a/docs/images/add-provider.png and b/docs/images/add-provider.png differ diff --git a/docs/images/cli/api-keys/create.png b/docs/images/cli/api-keys/create.png index 54218c88f8..5dff1c6b7b 100644 Binary files a/docs/images/cli/api-keys/create.png and b/docs/images/cli/api-keys/create.png differ diff --git a/docs/images/cli/api-keys/created.png b/docs/images/cli/api-keys/created.png index a64b6faa74..f6fd9448f9 100644 Binary files a/docs/images/cli/api-keys/created.png and b/docs/images/cli/api-keys/created.png differ diff --git a/docs/images/cli/api-keys/list.png b/docs/images/cli/api-keys/list.png index 3c6020acfc..cc6aaf599b 100644 Binary files a/docs/images/cli/api-keys/list.png and b/docs/images/cli/api-keys/list.png differ diff --git a/docs/images/cli/api-keys/management.png b/docs/images/cli/api-keys/management.png index 85ebab8d02..bdd5b19d1d 100644 Binary files a/docs/images/cli/api-keys/management.png and b/docs/images/cli/api-keys/management.png differ diff --git a/docs/images/cli/api-keys/update.png b/docs/images/cli/api-keys/update.png index 81cc574801..b22ea7c14c 100644 Binary files a/docs/images/cli/api-keys/update.png and b/docs/images/cli/api-keys/update.png differ diff --git a/docs/images/compliance.png b/docs/images/compliance.png index b08fcc3651..f6df1abadc 100644 Binary files a/docs/images/compliance.png and b/docs/images/compliance.png differ diff --git a/docs/images/compliance/prowler-app-compliance-card-download.png b/docs/images/compliance/prowler-app-compliance-card-download.png index ec652f8774..00095d9edd 100644 Binary files a/docs/images/compliance/prowler-app-compliance-card-download.png and b/docs/images/compliance/prowler-app-compliance-card-download.png differ diff --git a/docs/images/compliance/prowler-app-compliance-detail-download.png b/docs/images/compliance/prowler-app-compliance-detail-download.png index 96c4cfc980..9c4bd4ccf0 100644 Binary files a/docs/images/compliance/prowler-app-compliance-detail-download.png and b/docs/images/compliance/prowler-app-compliance-detail-download.png differ diff --git a/docs/images/compliance/prowler-app-compliance-detail-header.png b/docs/images/compliance/prowler-app-compliance-detail-header.png index 03065cc7a5..e792cfb69f 100644 Binary files a/docs/images/compliance/prowler-app-compliance-detail-header.png and b/docs/images/compliance/prowler-app-compliance-detail-header.png differ diff --git a/docs/images/compliance/prowler-app-compliance-overview.png b/docs/images/compliance/prowler-app-compliance-overview.png index 03302d1902..f2aedb4303 100644 Binary files a/docs/images/compliance/prowler-app-compliance-overview.png and b/docs/images/compliance/prowler-app-compliance-overview.png differ diff --git a/docs/images/compliance/prowler-app-compliance-requirements-accordion.png b/docs/images/compliance/prowler-app-compliance-requirements-accordion.png index 47ceba8f23..1530559f69 100644 Binary files a/docs/images/compliance/prowler-app-compliance-requirements-accordion.png and b/docs/images/compliance/prowler-app-compliance-requirements-accordion.png differ diff --git a/docs/images/compliance/prowler-app-compliance-threatscore-card.png b/docs/images/compliance/prowler-app-compliance-threatscore-card.png index 3bcf349c79..36e9b99744 100644 Binary files a/docs/images/compliance/prowler-app-compliance-threatscore-card.png and b/docs/images/compliance/prowler-app-compliance-threatscore-card.png differ diff --git a/docs/images/compliance/prowler-app-compliance-threatscore-detail.png b/docs/images/compliance/prowler-app-compliance-threatscore-detail.png index 57e909a94c..f2ba5ce0b5 100644 Binary files a/docs/images/compliance/prowler-app-compliance-threatscore-detail.png and b/docs/images/compliance/prowler-app-compliance-threatscore-detail.png differ diff --git a/docs/images/compliance_download.png b/docs/images/compliance_download.png index 32aed141b3..15b4cbc1f1 100644 Binary files a/docs/images/compliance_download.png and b/docs/images/compliance_download.png differ diff --git a/docs/images/compliance_section.png b/docs/images/compliance_section.png index 2b64031550..f610d7d122 100644 Binary files a/docs/images/compliance_section.png and b/docs/images/compliance_section.png differ diff --git a/docs/images/download_output.png b/docs/images/download_output.png index 452851b84d..0608d23939 100644 Binary files a/docs/images/download_output.png and b/docs/images/download_output.png differ diff --git a/docs/images/finding-groups-drawer.png b/docs/images/finding-groups-drawer.png index 2c07f63249..088040360f 100644 Binary files a/docs/images/finding-groups-drawer.png and b/docs/images/finding-groups-drawer.png differ diff --git a/docs/images/finding-groups-expanded.png b/docs/images/finding-groups-expanded.png index 677df0020f..e19508bb19 100644 Binary files a/docs/images/finding-groups-expanded.png and b/docs/images/finding-groups-expanded.png differ diff --git a/docs/images/finding-groups-list.png b/docs/images/finding-groups-list.png index e70d2bb969..db98f7f9f8 100644 Binary files a/docs/images/finding-groups-list.png and b/docs/images/finding-groups-list.png differ diff --git a/docs/images/finding-groups-other-findings.png b/docs/images/finding-groups-other-findings.png index 35605a7710..6d7a2490f7 100644 Binary files a/docs/images/finding-groups-other-findings.png and b/docs/images/finding-groups-other-findings.png differ diff --git a/docs/images/findings.png b/docs/images/findings.png index e2ea9c56ce..db98f7f9f8 100644 Binary files a/docs/images/findings.png and b/docs/images/findings.png differ diff --git a/docs/images/gcp-credentials.png b/docs/images/gcp-credentials.png index 4f6dae3b1e..f8d69704d5 100644 Binary files a/docs/images/gcp-credentials.png and b/docs/images/gcp-credentials.png differ diff --git a/docs/images/issues.png b/docs/images/issues.png index 009bc0d447..483bf25d8d 100644 Binary files a/docs/images/issues.png and b/docs/images/issues.png differ diff --git a/docs/images/kubernetes-credentials.png b/docs/images/kubernetes-credentials.png index b461ed218a..14c282d005 100644 Binary files a/docs/images/kubernetes-credentials.png and b/docs/images/kubernetes-credentials.png differ diff --git a/docs/images/log-in.png b/docs/images/log-in.png index 9e6d410abe..134a074a1a 100644 Binary files a/docs/images/log-in.png and b/docs/images/log-in.png differ diff --git a/docs/images/mutelist-ui-1.png b/docs/images/mutelist-ui-1.png index 8114e64fdf..d274e0b729 100644 Binary files a/docs/images/mutelist-ui-1.png and b/docs/images/mutelist-ui-1.png differ diff --git a/docs/images/mutelist-ui-2.png b/docs/images/mutelist-ui-2.png index 847f7c1b16..ae6af02bd7 100644 Binary files a/docs/images/mutelist-ui-2.png and b/docs/images/mutelist-ui-2.png differ diff --git a/docs/images/mutelist-ui-3.png b/docs/images/mutelist-ui-3.png index da1951c533..814247207b 100644 Binary files a/docs/images/mutelist-ui-3.png and b/docs/images/mutelist-ui-3.png differ diff --git a/docs/images/mutelist-ui-5.png b/docs/images/mutelist-ui-5.png index 128bf30731..1eab7bf6bd 100644 Binary files a/docs/images/mutelist-ui-5.png and b/docs/images/mutelist-ui-5.png differ diff --git a/docs/images/mutelist-ui-6.png b/docs/images/mutelist-ui-6.png index 659eccb61e..733deaaca5 100644 Binary files a/docs/images/mutelist-ui-6.png and b/docs/images/mutelist-ui-6.png differ diff --git a/docs/images/mutelist-ui-7.png b/docs/images/mutelist-ui-7.png index e6352c97e9..fa1ff660e6 100644 Binary files a/docs/images/mutelist-ui-7.png and b/docs/images/mutelist-ui-7.png differ diff --git a/docs/images/mutelist-ui-8.png b/docs/images/mutelist-ui-8.png index 54e2110edb..0e81f3d024 100644 Binary files a/docs/images/mutelist-ui-8.png and b/docs/images/mutelist-ui-8.png differ diff --git a/docs/images/mutelist-ui-9.png b/docs/images/mutelist-ui-9.png index cba2ece1a3..52e99db624 100644 Binary files a/docs/images/mutelist-ui-9.png and b/docs/images/mutelist-ui-9.png differ diff --git a/docs/images/organizations/cloud-providers-add.png b/docs/images/organizations/cloud-providers-add.png index 21d0fadff3..f0e78930e8 100644 Binary files a/docs/images/organizations/cloud-providers-add.png and b/docs/images/organizations/cloud-providers-add.png differ diff --git a/docs/images/organizations/launch-scan.png b/docs/images/organizations/launch-scan.png index 564c7674a1..ec6d3d3784 100644 Binary files a/docs/images/organizations/launch-scan.png and b/docs/images/organizations/launch-scan.png differ diff --git a/docs/images/organizations/organization-details-form.png b/docs/images/organizations/organization-details-form.png index 41b574f4c3..968098ad17 100644 Binary files a/docs/images/organizations/organization-details-form.png and b/docs/images/organizations/organization-details-form.png differ diff --git a/docs/images/organizations/role-arn-field.png b/docs/images/organizations/role-arn-field.png index 3f6832bfa4..a5e505b635 100644 Binary files a/docs/images/organizations/role-arn-field.png and b/docs/images/organizations/role-arn-field.png differ diff --git a/docs/images/organizations/select-aws-provider.png b/docs/images/organizations/select-aws-provider.png index 7b47b4b400..8702b1976d 100644 Binary files a/docs/images/organizations/select-aws-provider.png and b/docs/images/organizations/select-aws-provider.png differ diff --git a/docs/images/organizations/select-organizations-method.png b/docs/images/organizations/select-organizations-method.png index f4c4aa7c8f..67c38d74e4 100644 Binary files a/docs/images/organizations/select-organizations-method.png and b/docs/images/organizations/select-organizations-method.png differ diff --git a/docs/images/powerbi/download-compliance-scan.png b/docs/images/powerbi/download-compliance-scan.png index 4626d90edd..00095d9edd 100644 Binary files a/docs/images/powerbi/download-compliance-scan.png and b/docs/images/powerbi/download-compliance-scan.png differ diff --git a/docs/images/products/overview.png b/docs/images/products/overview.png index 697e1f74dc..0a3d53ad75 100644 Binary files a/docs/images/products/overview.png and b/docs/images/products/overview.png differ diff --git a/docs/images/provider-added.png b/docs/images/provider-added.png index cfb94d2cab..8dbcd8cec0 100644 Binary files a/docs/images/provider-added.png and b/docs/images/provider-added.png differ diff --git a/docs/images/providers/add-account-id.png b/docs/images/providers/add-account-id.png index bfded597a9..8df1cd41b7 100644 Binary files a/docs/images/providers/add-account-id.png and b/docs/images/providers/add-account-id.png differ diff --git a/docs/images/providers/add-alibaba-account-id.png b/docs/images/providers/add-alibaba-account-id.png index 2e49f995cc..7d0c403bcd 100644 Binary files a/docs/images/providers/add-alibaba-account-id.png and b/docs/images/providers/add-alibaba-account-id.png differ diff --git a/docs/images/providers/add-credentials-azure-prowler-cloud.png b/docs/images/providers/add-credentials-azure-prowler-cloud.png index 48b722a200..38047c0f1c 100644 Binary files a/docs/images/providers/add-credentials-azure-prowler-cloud.png and b/docs/images/providers/add-credentials-azure-prowler-cloud.png differ diff --git a/docs/images/providers/add-domain-id.png b/docs/images/providers/add-domain-id.png index ba1a4cf440..bed15413e8 100644 Binary files a/docs/images/providers/add-domain-id.png and b/docs/images/providers/add-domain-id.png differ diff --git a/docs/images/providers/add-github-account-id.png b/docs/images/providers/add-github-account-id.png index 899d772c60..ed306752cc 100644 Binary files a/docs/images/providers/add-github-account-id.png and b/docs/images/providers/add-github-account-id.png differ diff --git a/docs/images/providers/add-iac-repo.png b/docs/images/providers/add-iac-repo.png index 31981e7fd1..db9ea2e2f7 100644 Binary files a/docs/images/providers/add-iac-repo.png and b/docs/images/providers/add-iac-repo.png differ diff --git a/docs/images/providers/add-project-id.png b/docs/images/providers/add-project-id.png index 41b2971a06..e157e06da0 100644 Binary files a/docs/images/providers/add-project-id.png and b/docs/images/providers/add-project-id.png differ diff --git a/docs/images/providers/add-subscription-id.png b/docs/images/providers/add-subscription-id.png index 235fd377b8..bde1471834 100644 Binary files a/docs/images/providers/add-subscription-id.png and b/docs/images/providers/add-subscription-id.png differ diff --git a/docs/images/providers/alibaba-credentials-form.png b/docs/images/providers/alibaba-credentials-form.png index 3cefc0253c..67dd88fc2c 100644 Binary files a/docs/images/providers/alibaba-credentials-form.png and b/docs/images/providers/alibaba-credentials-form.png differ diff --git a/docs/images/providers/alibaba-get-role-arn.png b/docs/images/providers/alibaba-get-role-arn.png index d95822a18e..5a3f34e201 100644 Binary files a/docs/images/providers/alibaba-get-role-arn.png and b/docs/images/providers/alibaba-get-role-arn.png differ diff --git a/docs/images/providers/assume-role-overview.png b/docs/images/providers/assume-role-overview.png index d99ea0564e..cc6e346e2f 100644 Binary files a/docs/images/providers/assume-role-overview.png and b/docs/images/providers/assume-role-overview.png differ diff --git a/docs/images/providers/auth-github-app.png b/docs/images/providers/auth-github-app.png index ce7555f081..83ca1c93ef 100644 Binary files a/docs/images/providers/auth-github-app.png and b/docs/images/providers/auth-github-app.png differ diff --git a/docs/images/providers/auth-oauth.png b/docs/images/providers/auth-oauth.png index fd38330aa4..9555b6c565 100644 Binary files a/docs/images/providers/auth-oauth.png and b/docs/images/providers/auth-oauth.png differ diff --git a/docs/images/providers/auth-pat.png b/docs/images/providers/auth-pat.png index 573640851e..8c416ff6fd 100644 Binary files a/docs/images/providers/auth-pat.png and b/docs/images/providers/auth-pat.png differ diff --git a/docs/images/providers/certificate-form.png b/docs/images/providers/certificate-form.png index b19c079d23..d5616afeb6 100644 Binary files a/docs/images/providers/certificate-form.png and b/docs/images/providers/certificate-form.png differ diff --git a/docs/images/providers/click-next-azure.png b/docs/images/providers/click-next-azure.png index 4e2e90cd98..9a5b654743 100644 Binary files a/docs/images/providers/click-next-azure.png and b/docs/images/providers/click-next-azure.png differ diff --git a/docs/images/providers/click-next-m365.png b/docs/images/providers/click-next-m365.png index 50fed40735..d5f667c2df 100644 Binary files a/docs/images/providers/click-next-m365.png and b/docs/images/providers/click-next-m365.png differ diff --git a/docs/images/providers/cloudflare-account-id-form.png b/docs/images/providers/cloudflare-account-id-form.png index 4175c44713..e440139c29 100644 Binary files a/docs/images/providers/cloudflare-account-id-form.png and b/docs/images/providers/cloudflare-account-id-form.png differ diff --git a/docs/images/providers/cloudflare-api-email-form.png b/docs/images/providers/cloudflare-api-email-form.png index 7f9526e93c..3bd1f8f4ef 100644 Binary files a/docs/images/providers/cloudflare-api-email-form.png and b/docs/images/providers/cloudflare-api-email-form.png differ diff --git a/docs/images/providers/cloudflare-auth-selection.png b/docs/images/providers/cloudflare-auth-selection.png index 4a8610f050..997ba8de4c 100644 Binary files a/docs/images/providers/cloudflare-auth-selection.png and b/docs/images/providers/cloudflare-auth-selection.png differ diff --git a/docs/images/providers/cloudflare-token-form.png b/docs/images/providers/cloudflare-token-form.png index a147bcbfe2..e8d5d81fa4 100644 Binary files a/docs/images/providers/cloudflare-token-form.png and b/docs/images/providers/cloudflare-token-form.png differ diff --git a/docs/images/providers/connect-via-credentials.png b/docs/images/providers/connect-via-credentials.png index 4e3ba9e7f8..203f601e2e 100644 Binary files a/docs/images/providers/connect-via-credentials.png and b/docs/images/providers/connect-via-credentials.png differ diff --git a/docs/images/providers/googleworkspace-credentials-form.png b/docs/images/providers/googleworkspace-credentials-form.png index bc85df96b6..e6410a9fa7 100644 Binary files a/docs/images/providers/googleworkspace-credentials-form.png and b/docs/images/providers/googleworkspace-credentials-form.png differ diff --git a/docs/images/providers/googleworkspace-customer-id-form.png b/docs/images/providers/googleworkspace-customer-id-form.png index ced135e5eb..a5f49dc588 100644 Binary files a/docs/images/providers/googleworkspace-customer-id-form.png and b/docs/images/providers/googleworkspace-customer-id-form.png differ diff --git a/docs/images/providers/iac-authentication.png b/docs/images/providers/iac-authentication.png index c6caad4ae1..5807ddb573 100644 Binary files a/docs/images/providers/iac-authentication.png and b/docs/images/providers/iac-authentication.png differ diff --git a/docs/images/providers/iac-verify-connection.png b/docs/images/providers/iac-verify-connection.png index bc918f6c24..f225363448 100644 Binary files a/docs/images/providers/iac-verify-connection.png and b/docs/images/providers/iac-verify-connection.png differ diff --git a/docs/images/providers/launch-scan-alibaba.png b/docs/images/providers/launch-scan-alibaba.png index 324e1334c4..0b210be974 100644 Binary files a/docs/images/providers/launch-scan-alibaba.png and b/docs/images/providers/launch-scan-alibaba.png differ diff --git a/docs/images/providers/launch-scan-button-prowler-cloud.png b/docs/images/providers/launch-scan-button-prowler-cloud.png index 06b7e37a85..ec6d3d3784 100644 Binary files a/docs/images/providers/launch-scan-button-prowler-cloud.png and b/docs/images/providers/launch-scan-button-prowler-cloud.png differ diff --git a/docs/images/providers/launch-scan.png b/docs/images/providers/launch-scan.png index 07601cf63a..ec6d3d3784 100644 Binary files a/docs/images/providers/launch-scan.png and b/docs/images/providers/launch-scan.png differ diff --git a/docs/images/providers/m365-auth-selection-form.png b/docs/images/providers/m365-auth-selection-form.png index 4757f44d96..40992fa38f 100644 Binary files a/docs/images/providers/m365-auth-selection-form.png and b/docs/images/providers/m365-auth-selection-form.png differ diff --git a/docs/images/providers/next-button-prowler-cloud.png b/docs/images/providers/next-button-prowler-cloud.png index f41437c17e..26fe233f5d 100644 Binary files a/docs/images/providers/next-button-prowler-cloud.png and b/docs/images/providers/next-button-prowler-cloud.png differ diff --git a/docs/images/providers/paste-role-arn-prowler.png b/docs/images/providers/paste-role-arn-prowler.png index 82d7165334..8d9baf91b8 100644 Binary files a/docs/images/providers/paste-role-arn-prowler.png and b/docs/images/providers/paste-role-arn-prowler.png differ diff --git a/docs/images/providers/prowler-cloud-credentials-next.png b/docs/images/providers/prowler-cloud-credentials-next.png index 0f73b00905..f8fe6a5659 100644 Binary files a/docs/images/providers/prowler-cloud-credentials-next.png and b/docs/images/providers/prowler-cloud-credentials-next.png differ diff --git a/docs/images/providers/prowler-cloud-external-id.png b/docs/images/providers/prowler-cloud-external-id.png index 79fb1b19e3..aa58cf9c09 100644 Binary files a/docs/images/providers/prowler-cloud-external-id.png and b/docs/images/providers/prowler-cloud-external-id.png differ diff --git a/docs/images/providers/secret-form.png b/docs/images/providers/secret-form.png index e202c56aab..72dc427f2e 100644 Binary files a/docs/images/providers/secret-form.png and b/docs/images/providers/secret-form.png differ diff --git a/docs/images/providers/select-alibaba-cloud.png b/docs/images/providers/select-alibaba-cloud.png index 8b65931472..019a62b9d7 100644 Binary files a/docs/images/providers/select-alibaba-cloud.png and b/docs/images/providers/select-alibaba-cloud.png differ diff --git a/docs/images/providers/select-auth-method-alibaba.png b/docs/images/providers/select-auth-method-alibaba.png index ffd0083bf0..215a683bed 100644 Binary files a/docs/images/providers/select-auth-method-alibaba.png and b/docs/images/providers/select-auth-method-alibaba.png differ diff --git a/docs/images/providers/select-auth-method.png b/docs/images/providers/select-auth-method.png index cab2c844ce..207c870360 100644 Binary files a/docs/images/providers/select-auth-method.png and b/docs/images/providers/select-auth-method.png differ diff --git a/docs/images/providers/select-aws.png b/docs/images/providers/select-aws.png index f7d08ae628..8702b1976d 100644 Binary files a/docs/images/providers/select-aws.png and b/docs/images/providers/select-aws.png differ diff --git a/docs/images/providers/select-azure-prowler-cloud.png b/docs/images/providers/select-azure-prowler-cloud.png index 2b8b473d0a..bb53336c0e 100644 Binary files a/docs/images/providers/select-azure-prowler-cloud.png and b/docs/images/providers/select-azure-prowler-cloud.png differ diff --git a/docs/images/providers/select-cloudflare-prowler-cloud.png b/docs/images/providers/select-cloudflare-prowler-cloud.png index 508f17d595..df36ae87e3 100644 Binary files a/docs/images/providers/select-cloudflare-prowler-cloud.png and b/docs/images/providers/select-cloudflare-prowler-cloud.png differ diff --git a/docs/images/providers/select-gcp.png b/docs/images/providers/select-gcp.png index 0aed14394c..1f9cee49b5 100644 Binary files a/docs/images/providers/select-gcp.png and b/docs/images/providers/select-gcp.png differ diff --git a/docs/images/providers/select-github.png b/docs/images/providers/select-github.png index 5208e658d0..65e0e665a4 100644 Binary files a/docs/images/providers/select-github.png and b/docs/images/providers/select-github.png differ diff --git a/docs/images/providers/select-googleworkspace-prowler-cloud.png b/docs/images/providers/select-googleworkspace-prowler-cloud.png index b8a83b6e83..0802a0562a 100644 Binary files a/docs/images/providers/select-googleworkspace-prowler-cloud.png and b/docs/images/providers/select-googleworkspace-prowler-cloud.png differ diff --git a/docs/images/providers/select-iac.png b/docs/images/providers/select-iac.png index 1dc474cbe8..6fedc1dcd3 100644 Binary files a/docs/images/providers/select-iac.png and b/docs/images/providers/select-iac.png differ diff --git a/docs/images/providers/select-m365-prowler-cloud.png b/docs/images/providers/select-m365-prowler-cloud.png index 6507c89839..1424cadb1a 100644 Binary files a/docs/images/providers/select-m365-prowler-cloud.png and b/docs/images/providers/select-m365-prowler-cloud.png differ diff --git a/docs/images/providers/select-vercel-prowler-cloud.png b/docs/images/providers/select-vercel-prowler-cloud.png index b332103e1f..40f5d774c7 100644 Binary files a/docs/images/providers/select-vercel-prowler-cloud.png and b/docs/images/providers/select-vercel-prowler-cloud.png differ diff --git a/docs/images/providers/vercel-team-id-form.png b/docs/images/providers/vercel-team-id-form.png index fad53fe017..d1e6185800 100644 Binary files a/docs/images/providers/vercel-team-id-form.png and b/docs/images/providers/vercel-team-id-form.png differ diff --git a/docs/images/providers/vercel-token-form.png b/docs/images/providers/vercel-token-form.png index 991b9ddedc..136b0cce78 100644 Binary files a/docs/images/providers/vercel-token-form.png and b/docs/images/providers/vercel-token-form.png differ diff --git a/docs/images/prowler-app/add-cloud-provider.png b/docs/images/prowler-app/add-cloud-provider.png index d8f19b2054..4dc0749a27 100644 Binary files a/docs/images/prowler-app/add-cloud-provider.png and b/docs/images/prowler-app/add-cloud-provider.png differ diff --git a/docs/images/prowler-app/alerts/alerts-list.png b/docs/images/prowler-app/alerts/alerts-list.png index 7bb03415fa..153f5d6b24 100644 Binary files a/docs/images/prowler-app/alerts/alerts-list.png and b/docs/images/prowler-app/alerts/alerts-list.png differ diff --git a/docs/images/prowler-app/alerts/create-alert-from-findings.png b/docs/images/prowler-app/alerts/create-alert-from-findings.png index 5524389877..845aa627d3 100644 Binary files a/docs/images/prowler-app/alerts/create-alert-from-findings.png and b/docs/images/prowler-app/alerts/create-alert-from-findings.png differ diff --git a/docs/images/prowler-app/alerts/create-alert-modal.png b/docs/images/prowler-app/alerts/create-alert-modal.png index 54924638af..46ef36b690 100644 Binary files a/docs/images/prowler-app/alerts/create-alert-modal.png and b/docs/images/prowler-app/alerts/create-alert-modal.png differ diff --git a/docs/images/prowler-app/alerts/edit-alert-test.png b/docs/images/prowler-app/alerts/edit-alert-test.png index 252a0cf67c..2047792ca4 100644 Binary files a/docs/images/prowler-app/alerts/edit-alert-test.png and b/docs/images/prowler-app/alerts/edit-alert-test.png differ diff --git a/docs/images/prowler-app/attack-paths/query-no-data.png b/docs/images/prowler-app/attack-paths/query-no-data.png new file mode 100644 index 0000000000..33de2b9d3d Binary files /dev/null and b/docs/images/prowler-app/attack-paths/query-no-data.png differ diff --git a/docs/images/prowler-app/attack-paths/query-selector-hidden-empty.png b/docs/images/prowler-app/attack-paths/query-selector-hidden-empty.png new file mode 100644 index 0000000000..9f4fbd122d Binary files /dev/null and b/docs/images/prowler-app/attack-paths/query-selector-hidden-empty.png differ diff --git a/docs/images/prowler-app/cloud-providers-page.png b/docs/images/prowler-app/cloud-providers-page.png index dcbce73a10..6a81a9d0c1 100644 Binary files a/docs/images/prowler-app/cloud-providers-page.png and b/docs/images/prowler-app/cloud-providers-page.png differ diff --git a/docs/images/prowler-app/gcp-auth-methods.png b/docs/images/prowler-app/gcp-auth-methods.png index dc8681396e..ac5f2e5c0e 100644 Binary files a/docs/images/prowler-app/gcp-auth-methods.png and b/docs/images/prowler-app/gcp-auth-methods.png differ diff --git a/docs/images/prowler-app/gcp-service-account-creds.png b/docs/images/prowler-app/gcp-service-account-creds.png index af09776ab1..6d80ea5ca0 100644 Binary files a/docs/images/prowler-app/gcp-service-account-creds.png and b/docs/images/prowler-app/gcp-service-account-creds.png differ diff --git a/docs/images/prowler-app/multi-tenant/create-organization-button.png b/docs/images/prowler-app/multi-tenant/create-organization-button.png index 70675c334f..ad62ce339e 100644 Binary files a/docs/images/prowler-app/multi-tenant/create-organization-button.png and b/docs/images/prowler-app/multi-tenant/create-organization-button.png differ diff --git a/docs/images/prowler-app/multi-tenant/create-organization-modal.png b/docs/images/prowler-app/multi-tenant/create-organization-modal.png index f0f932035c..30f953dfe1 100644 Binary files a/docs/images/prowler-app/multi-tenant/create-organization-modal.png and b/docs/images/prowler-app/multi-tenant/create-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/delete-active-organization-modal.png b/docs/images/prowler-app/multi-tenant/delete-active-organization-modal.png index 41f5231753..cc4aa4c404 100644 Binary files a/docs/images/prowler-app/multi-tenant/delete-active-organization-modal.png and b/docs/images/prowler-app/multi-tenant/delete-active-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/delete-organization-modal.png b/docs/images/prowler-app/multi-tenant/delete-organization-modal.png index dd06f937e9..88265ab766 100644 Binary files a/docs/images/prowler-app/multi-tenant/delete-organization-modal.png and b/docs/images/prowler-app/multi-tenant/delete-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/edit-organization-modal.png b/docs/images/prowler-app/multi-tenant/edit-organization-modal.png index e0d28c727d..ef23c04c03 100644 Binary files a/docs/images/prowler-app/multi-tenant/edit-organization-modal.png and b/docs/images/prowler-app/multi-tenant/edit-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png b/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png index ed3b190c61..7a9b851295 100644 Binary files a/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png and b/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/expel-user-organization.png b/docs/images/prowler-app/multi-tenant/expel-user-organization.png index 09f72e04ea..11f270b8b9 100644 Binary files a/docs/images/prowler-app/multi-tenant/expel-user-organization.png and b/docs/images/prowler-app/multi-tenant/expel-user-organization.png differ diff --git a/docs/images/prowler-app/multi-tenant/organizations-card.png b/docs/images/prowler-app/multi-tenant/organizations-card.png index 10ddb4b15b..f401bac7ab 100644 Binary files a/docs/images/prowler-app/multi-tenant/organizations-card.png and b/docs/images/prowler-app/multi-tenant/organizations-card.png differ diff --git a/docs/images/prowler-app/rbac/invitation_details.png b/docs/images/prowler-app/rbac/invitation_details.png index 656a698308..f2d2169508 100644 Binary files a/docs/images/prowler-app/rbac/invitation_details.png and b/docs/images/prowler-app/rbac/invitation_details.png differ diff --git a/docs/images/prowler-app/rbac/invitation_details_1.png b/docs/images/prowler-app/rbac/invitation_details_1.png index e167db74af..7a51736cbe 100644 Binary files a/docs/images/prowler-app/rbac/invitation_details_1.png and b/docs/images/prowler-app/rbac/invitation_details_1.png differ diff --git a/docs/images/prowler-app/rbac/invitation_edit.png b/docs/images/prowler-app/rbac/invitation_edit.png index ef3d81f192..d256d0593c 100644 Binary files a/docs/images/prowler-app/rbac/invitation_edit.png and b/docs/images/prowler-app/rbac/invitation_edit.png differ diff --git a/docs/images/prowler-app/rbac/invitation_edit_1.png b/docs/images/prowler-app/rbac/invitation_edit_1.png index 6d1a1d2223..53761025ed 100644 Binary files a/docs/images/prowler-app/rbac/invitation_edit_1.png and b/docs/images/prowler-app/rbac/invitation_edit_1.png differ diff --git a/docs/images/prowler-app/rbac/invitation_info.png b/docs/images/prowler-app/rbac/invitation_info.png index a6ec05f976..1fbbf14c62 100644 Binary files a/docs/images/prowler-app/rbac/invitation_info.png and b/docs/images/prowler-app/rbac/invitation_info.png differ diff --git a/docs/images/prowler-app/rbac/invitation_revoke.png b/docs/images/prowler-app/rbac/invitation_revoke.png index 6c4e042c16..ab83853501 100644 Binary files a/docs/images/prowler-app/rbac/invitation_revoke.png and b/docs/images/prowler-app/rbac/invitation_revoke.png differ diff --git a/docs/images/prowler-app/rbac/invitation_sign-up.png b/docs/images/prowler-app/rbac/invitation_sign-up.png index f5b67a7762..c4e040f9f1 100644 Binary files a/docs/images/prowler-app/rbac/invitation_sign-up.png and b/docs/images/prowler-app/rbac/invitation_sign-up.png differ diff --git a/docs/images/prowler-app/rbac/invite.png b/docs/images/prowler-app/rbac/invite.png index dd60aba314..54cbc27d6b 100644 Binary files a/docs/images/prowler-app/rbac/invite.png and b/docs/images/prowler-app/rbac/invite.png differ diff --git a/docs/images/prowler-app/rbac/user_edit.png b/docs/images/prowler-app/rbac/user_edit.png index 421ea93156..1fab60d182 100644 Binary files a/docs/images/prowler-app/rbac/user_edit.png and b/docs/images/prowler-app/rbac/user_edit.png differ diff --git a/docs/images/prowler-app/rbac/user_edit_details.png b/docs/images/prowler-app/rbac/user_edit_details.png index 3e4734f142..7744f0472a 100644 Binary files a/docs/images/prowler-app/rbac/user_edit_details.png and b/docs/images/prowler-app/rbac/user_edit_details.png differ diff --git a/docs/images/prowler-app/rbac/user_remove.png b/docs/images/prowler-app/rbac/user_remove.png index 4c368ded66..11f270b8b9 100644 Binary files a/docs/images/prowler-app/rbac/user_remove.png and b/docs/images/prowler-app/rbac/user_remove.png differ diff --git a/docs/images/prowler-app/s3/s3-integration-ui-1.png b/docs/images/prowler-app/s3/s3-integration-ui-1.png index 1081f20453..6c60831843 100644 Binary files a/docs/images/prowler-app/s3/s3-integration-ui-1.png and b/docs/images/prowler-app/s3/s3-integration-ui-1.png differ diff --git a/docs/images/prowler-app/s3/s3-integration-ui-2.png b/docs/images/prowler-app/s3/s3-integration-ui-2.png index 618621ce26..019c29675b 100644 Binary files a/docs/images/prowler-app/s3/s3-integration-ui-2.png and b/docs/images/prowler-app/s3/s3-integration-ui-2.png differ diff --git a/docs/images/prowler-app/s3/s3-integration-ui-3.png b/docs/images/prowler-app/s3/s3-integration-ui-3.png index 6fabbe2ed3..a82a3109db 100644 Binary files a/docs/images/prowler-app/s3/s3-integration-ui-3.png and b/docs/images/prowler-app/s3/s3-integration-ui-3.png differ diff --git a/docs/images/prowler-app/s3/s3-integration-ui-4.png b/docs/images/prowler-app/s3/s3-integration-ui-4.png index 1967eda8af..867ac7d48d 100644 Binary files a/docs/images/prowler-app/s3/s3-integration-ui-4.png and b/docs/images/prowler-app/s3/s3-integration-ui-4.png differ diff --git a/docs/images/prowler-app/s3/s3-integration-ui-5.png b/docs/images/prowler-app/s3/s3-integration-ui-5.png index ffdea752f1..4f1e9e7ebf 100644 Binary files a/docs/images/prowler-app/s3/s3-integration-ui-5.png and b/docs/images/prowler-app/s3/s3-integration-ui-5.png differ diff --git a/docs/images/prowler-app/s3/s3-integration-ui-6.png b/docs/images/prowler-app/s3/s3-integration-ui-6.png index 330588e17b..eeb43679ef 100644 Binary files a/docs/images/prowler-app/s3/s3-integration-ui-6.png and b/docs/images/prowler-app/s3/s3-integration-ui-6.png differ diff --git a/docs/images/prowler-app/s3/s3-integration-ui-7.png b/docs/images/prowler-app/s3/s3-integration-ui-7.png index 3448f82a48..dd0e155d46 100644 Binary files a/docs/images/prowler-app/s3/s3-integration-ui-7.png and b/docs/images/prowler-app/s3/s3-integration-ui-7.png differ diff --git a/docs/images/prowler-app/saml/saml-signin-1.png b/docs/images/prowler-app/saml/saml-signin-1.png index 5da2e84711..35522436bb 100644 Binary files a/docs/images/prowler-app/saml/saml-signin-1.png and b/docs/images/prowler-app/saml/saml-signin-1.png differ diff --git a/docs/images/prowler-app/saml/saml-signin-2.png b/docs/images/prowler-app/saml/saml-signin-2.png index f0f7082fc9..9245d36f8b 100644 Binary files a/docs/images/prowler-app/saml/saml-signin-2.png and b/docs/images/prowler-app/saml/saml-signin-2.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-enabled.png b/docs/images/prowler-app/saml/saml-sso-enabled.png new file mode 100644 index 0000000000..6c705ba00c Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-enabled.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-remove.png b/docs/images/prowler-app/saml/saml-sso-remove.png new file mode 100644 index 0000000000..dd61a26cd1 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-remove.png differ diff --git a/docs/images/prowler-app/saml/saml-step-1.png b/docs/images/prowler-app/saml/saml-step-1.png index d505c2597c..694e17ca27 100644 Binary files a/docs/images/prowler-app/saml/saml-step-1.png and b/docs/images/prowler-app/saml/saml-step-1.png differ diff --git a/docs/images/prowler-app/saml/saml-step-2.png b/docs/images/prowler-app/saml/saml-step-2.png index ddb036c98d..3bcb664701 100644 Binary files a/docs/images/prowler-app/saml/saml-step-2.png and b/docs/images/prowler-app/saml/saml-step-2.png differ diff --git a/docs/images/prowler-app/saml/saml-step-3.png b/docs/images/prowler-app/saml/saml-step-3.png index 2b8dfbd845..fe5ee492a3 100644 Binary files a/docs/images/prowler-app/saml/saml-step-3.png and b/docs/images/prowler-app/saml/saml-step-3.png differ diff --git a/docs/images/prowler-app/saml/saml-step-4.png b/docs/images/prowler-app/saml/saml-step-4.png deleted file mode 100644 index cca0987c50..0000000000 Binary files a/docs/images/prowler-app/saml/saml-step-4.png and /dev/null differ diff --git a/docs/images/prowler-app/saml/saml-step-remove.png b/docs/images/prowler-app/saml/saml-step-remove.png deleted file mode 100644 index f0868691af..0000000000 Binary files a/docs/images/prowler-app/saml/saml-step-remove.png and /dev/null differ diff --git a/docs/images/prowler-app/security-hub/create-integration.png b/docs/images/prowler-app/security-hub/create-integration.png index 145e68d201..dbafd21672 100644 Binary files a/docs/images/prowler-app/security-hub/create-integration.png and b/docs/images/prowler-app/security-hub/create-integration.png differ diff --git a/docs/images/prowler-app/security-hub/integration-settings.png b/docs/images/prowler-app/security-hub/integration-settings.png index 3a32bf0830..3201904c23 100644 Binary files a/docs/images/prowler-app/security-hub/integration-settings.png and b/docs/images/prowler-app/security-hub/integration-settings.png differ diff --git a/docs/images/prowler-app/security-hub/integrations-tab.png b/docs/images/prowler-app/security-hub/integrations-tab.png index 9d11cae33a..af4178b964 100644 Binary files a/docs/images/prowler-app/security-hub/integrations-tab.png and b/docs/images/prowler-app/security-hub/integrations-tab.png differ diff --git a/docs/images/prowler-app/social-login/social_login_buttons.png b/docs/images/prowler-app/social-login/social_login_buttons.png index 476081e0fc..9adaaac790 100644 Binary files a/docs/images/prowler-app/social-login/social_login_buttons.png and b/docs/images/prowler-app/social-login/social_login_buttons.png differ diff --git a/docs/images/prowler-app/social-login/social_login_buttons_disabled.png b/docs/images/prowler-app/social-login/social_login_buttons_disabled.png index 7b11a7802d..fd46318b65 100644 Binary files a/docs/images/prowler-app/social-login/social_login_buttons_disabled.png and b/docs/images/prowler-app/social-login/social_login_buttons_disabled.png differ diff --git a/docs/images/scan-progress.png b/docs/images/scan-progress.png index 3378775dcd..dd57820410 100644 Binary files a/docs/images/scan-progress.png and b/docs/images/scan-progress.png differ diff --git a/docs/images/select-provider.png b/docs/images/select-provider.png index 3151bfe5e7..95607f8657 100644 Binary files a/docs/images/select-provider.png and b/docs/images/select-provider.png differ diff --git a/docs/images/sign-up-button.png b/docs/images/sign-up-button.png index b7006e72e5..475d00d81e 100644 Binary files a/docs/images/sign-up-button.png and b/docs/images/sign-up-button.png differ diff --git a/docs/images/sign-up.png b/docs/images/sign-up.png index 56e8901b24..8fa20ebf96 100644 Binary files a/docs/images/sign-up.png and b/docs/images/sign-up.png differ diff --git a/docs/images/test-connection-button.png b/docs/images/test-connection-button.png index 261cb035c7..38bd5c8a7c 100644 Binary files a/docs/images/test-connection-button.png and b/docs/images/test-connection-button.png differ diff --git a/docs/style.css b/docs/style.css index edbb1fbfcf..562d3ed650 100644 --- a/docs/style.css +++ b/docs/style.css @@ -88,6 +88,7 @@ li[data-title="Prowler for AI Agents"] > button span:first-child::after, div:has(+ ul a[href="/security/encryption"]) h3 span::after, li[id="/user-guide/compliance/tutorials/cross-provider-compliance"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after, diff --git a/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx index 545f5fb834..afc24e6f22 100644 --- a/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx +++ b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx @@ -1,7 +1,7 @@ --- title: 'Cross-Provider Compliance' sidebarTitle: 'Cross-Provider Compliance' -description: 'Aggregate a single universal compliance framework across every connected provider in Prowler Cloud, review a consolidated roll-up and per-provider breakdown, and download a combined PDF report.' +description: 'Aggregate a universal compliance framework across compatible providers with completed scans, or a single-provider framework across every provider of one type, review consolidated roll-ups and per-provider breakdowns, and download combined PDF reports.' --- import { VersionBadge } from "/snippets/version-badge.mdx" @@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" -Cross-Provider Compliance consolidates a single **universal compliance framework** across all of your connected providers into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and every other provider as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF. +Cross-Provider Compliance consolidates a single **universal compliance framework** across your compatible providers with completed scans into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and other supported providers as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF. @@ -50,20 +50,25 @@ The catalog grows as new universal frameworks ship in Prowler. Browse the full c Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) and select **Compliance** from the left navigation. - - At the top of the Compliance page, select the **Cross-provider** tab. The **Per Scan** tab (the default) keeps the single-provider compliance experience unchanged. + + At the top of the Compliance page, select the **Multiple Scans** tab. The **Single Scan** tab (the default) keeps the single-provider compliance experience unchanged. -Compliance page showing the Per Scan and Cross-provider tabs, with the Cross-provider tab highlighted +Compliance page showing the Single Scan and Multiple Scans tabs, with the Multiple Scans tab highlighted -Cross-Provider Compliance requires at least one completed scan for a compatible provider. If no compatible provider has finished a scan yet, the page shows a notice prompting you to launch or wait for a scan to complete. +The **Across provider types** section requires at least one completed scan for a provider compatible with a universal framework. If none is available, that section shows a notice prompting you to launch or wait for a scan to complete. The **Across providers** section can still list single-provider frameworks when its own account and scan requirements are met. ## Exploring the Overview -The overview presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider. +The Multiple Scans tab is organized into two sections, each labeled with the axis it aggregates across: + +* **Across provider types:** One card per supported universal framework, aggregating every compatible provider type. This is the Cross-Provider Compliance experience described in the rest of this guide. +* **Across providers:** One card per single-provider framework (for example, CIS AWS) that can be aggregated across every connected provider of the same type. See [Aggregating a Single-Provider Framework Across Providers](#aggregating-a-single-provider-framework-across-providers). + +The **Across provider types** section presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider. Cross-Provider Compliance overview showing the provider filters and the framework grid (CSA CCM, CIS Controls, DORA) with per-provider chips, scores, and failed/manual counts @@ -193,7 +198,7 @@ Because the report aggregates many scans, it is generated **asynchronously**: A report already generated for a given set of filters does not need to be generated again. When you open the detail page with a filter combination that was reported before, Prowler detects the existing report and surfaces **Report → Download latest** so you can download it immediately, without launching a new job. You only need to generate a fresh report when: * You apply a filter combination that has never been reported before, or -* A contributing provider has run a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one. +* A contributing provider has completed a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer completed scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one. "Download latest" reuses an existing report only when it matches the framework, the exact resolved scan set for the current filters, and the same report options. This guarantees the PDF you download reflects the posture you are looking at, rather than a report generated for a different filter or an older scan. @@ -201,6 +206,27 @@ A report already generated for a given set of filters does not need to be genera The PDF detail section renders only **failed** requirements by default so the report stays focused as an executive/auditor document. As with every Prowler PDF, the detail section is capped at the first 100 failed findings per check; use the per-scan CSV or JSON-OCSF exports for the complete, untruncated list. See [Downloading Compliance Reports](/user-guide/compliance/tutorials/compliance#downloading-compliance-reports) for the full PDF behavior and the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` setting. +## Aggregating a Single-Provider Framework Across Providers + +Universal frameworks answer the cross-provider-type question, but most compliance frameworks target a single provider type — CIS AWS, CIS GCP, ENS for Azure. The **Across providers** section of the Multiple Scans tab answers the sibling question for those frameworks: **"How compliant are all of my AWS accounts against CIS AWS, together?"** + +For every provider type with **two or more connected providers**, the section shows a collapsible group headed by the provider type and its counts (frameworks available and providers connected). Expanding a group reveals one card per single-provider framework available for that type, keeping the section compact even when several multi-provider types are connected. Selecting a card opens a detail page with the same layout as the cross-provider detail, with the column axis swapped from provider type to provider: + +* **One column per provider:** Prowler auto-selects the latest completed scan of every provider of that type you are allowed to see, and each requirement shows a status chip per provider (labeled with its alias or account ID). +* **Account Coverage:** The coverage card ranks each provider's individual posture so the weakest account is visible at a glance. +* **Filters:** Narrow the aggregation to specific providers or provider groups. The provider type is fixed by the framework, so there is no type filter here. +* **Findings drill-down:** Expanding a requirement queries the findings of every contributing provider's scan and merges them into a single table. + +The roll-up rules are identical to the cross-provider ones (**FAIL > PASS > MANUAL**, only providers that contributed a result count), applied per provider instead of per provider type. Scan selection, RBAC scoping, and staleness behavior also match: the view always reflects each provider's most recent completed scan, scoped to your role's visibility. + + +Provider types with a single connected provider are not listed — with one provider the aggregation is identical to the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view. + + +### Cross-Account PDF Report + +The detail page's **Report** button produces a single combined PDF across every contributing provider of the type, with the same asynchronous generation, background notification, and **Download latest** reuse flow as the [cross-provider report](#downloading-the-combined-pdf-report). The report is tied to the exact resolved scan set, so a new completed scan in any contributing provider makes the previous report stale and Prowler offers to generate an up-to-date one. + ## Related Documentation * [Compliance](/user-guide/compliance/tutorials/compliance) diff --git a/docs/user-guide/img/add-registry-url.png b/docs/user-guide/img/add-registry-url.png index df1319ea15..eae438fdbf 100644 Binary files a/docs/user-guide/img/add-registry-url.png and b/docs/user-guide/img/add-registry-url.png differ diff --git a/docs/user-guide/img/image-authentication-filters.png b/docs/user-guide/img/image-authentication-filters.png index da56306e83..af198c45b5 100644 Binary files a/docs/user-guide/img/image-authentication-filters.png and b/docs/user-guide/img/image-authentication-filters.png differ diff --git a/docs/user-guide/img/select-container-registry.png b/docs/user-guide/img/select-container-registry.png index 50fd9c3177..d07faaa768 100644 Binary files a/docs/user-guide/img/select-container-registry.png and b/docs/user-guide/img/select-container-registry.png differ diff --git a/docs/user-guide/providers/aws/img/select-auth-method.png b/docs/user-guide/providers/aws/img/select-auth-method.png index 17d26dec41..97a8a60016 100644 Binary files a/docs/user-guide/providers/aws/img/select-auth-method.png and b/docs/user-guide/providers/aws/img/select-auth-method.png differ diff --git a/docs/user-guide/providers/gcp/img/launch-scan.png b/docs/user-guide/providers/gcp/img/launch-scan.png index 49511e5300..025da0a6a5 100644 Binary files a/docs/user-guide/providers/gcp/img/launch-scan.png and b/docs/user-guide/providers/gcp/img/launch-scan.png differ diff --git a/docs/user-guide/providers/mongodbatlas/img/add-credentials.png b/docs/user-guide/providers/mongodbatlas/img/add-credentials.png index 646a8e9422..9db07b5dc4 100644 Binary files a/docs/user-guide/providers/mongodbatlas/img/add-credentials.png and b/docs/user-guide/providers/mongodbatlas/img/add-credentials.png differ diff --git a/docs/user-guide/providers/mongodbatlas/img/add-org-id.png b/docs/user-guide/providers/mongodbatlas/img/add-org-id.png index b8306e95b0..11b251e9a1 100644 Binary files a/docs/user-guide/providers/mongodbatlas/img/add-org-id.png and b/docs/user-guide/providers/mongodbatlas/img/add-org-id.png differ diff --git a/docs/user-guide/providers/mongodbatlas/img/add-provider-list.png b/docs/user-guide/providers/mongodbatlas/img/add-provider-list.png index 72e8341227..cbcb487ebb 100644 Binary files a/docs/user-guide/providers/mongodbatlas/img/add-provider-list.png and b/docs/user-guide/providers/mongodbatlas/img/add-provider-list.png differ diff --git a/docs/user-guide/providers/oci/images/oci-add-api-key-credentials.png b/docs/user-guide/providers/oci/images/oci-add-api-key-credentials.png index 886b6ee876..38bf82f371 100644 Binary files a/docs/user-guide/providers/oci/images/oci-add-api-key-credentials.png and b/docs/user-guide/providers/oci/images/oci-add-api-key-credentials.png differ diff --git a/docs/user-guide/providers/oci/images/oci-add-cloud-provider.png b/docs/user-guide/providers/oci/images/oci-add-cloud-provider.png index f4757afad6..d8860df172 100644 Binary files a/docs/user-guide/providers/oci/images/oci-add-cloud-provider.png and b/docs/user-guide/providers/oci/images/oci-add-cloud-provider.png differ diff --git a/docs/user-guide/providers/oci/images/oci-add-tenancy.png b/docs/user-guide/providers/oci/images/oci-add-tenancy.png index a671272034..10dfe0f1a2 100644 Binary files a/docs/user-guide/providers/oci/images/oci-add-tenancy.png and b/docs/user-guide/providers/oci/images/oci-add-tenancy.png differ diff --git a/docs/user-guide/providers/okta/images/okta-credentials-form.png b/docs/user-guide/providers/okta/images/okta-credentials-form.png index c37553c59d..20843b6555 100644 Binary files a/docs/user-guide/providers/okta/images/okta-credentials-form.png and b/docs/user-guide/providers/okta/images/okta-credentials-form.png differ diff --git a/docs/user-guide/providers/okta/images/okta-org-domain-form.png b/docs/user-guide/providers/okta/images/okta-org-domain-form.png index fb145d2877..a43f36b57d 100644 Binary files a/docs/user-guide/providers/okta/images/okta-org-domain-form.png and b/docs/user-guide/providers/okta/images/okta-org-domain-form.png differ diff --git a/docs/user-guide/providers/okta/images/select-okta-provider.png b/docs/user-guide/providers/okta/images/select-okta-provider.png index 1a854bab37..23b869c124 100644 Binary files a/docs/user-guide/providers/okta/images/select-okta-provider.png and b/docs/user-guide/providers/okta/images/select-okta-provider.png differ diff --git a/docs/user-guide/providers/openstack/images/add-credentials.png b/docs/user-guide/providers/openstack/images/add-credentials.png index c2583e5358..2f317fad22 100644 Binary files a/docs/user-guide/providers/openstack/images/add-credentials.png and b/docs/user-guide/providers/openstack/images/add-credentials.png differ diff --git a/docs/user-guide/providers/openstack/images/add-provider-id.png b/docs/user-guide/providers/openstack/images/add-provider-id.png index 75266903fd..a2e4e900ef 100644 Binary files a/docs/user-guide/providers/openstack/images/add-provider-id.png and b/docs/user-guide/providers/openstack/images/add-provider-id.png differ diff --git a/docs/user-guide/providers/openstack/images/select-provider.png b/docs/user-guide/providers/openstack/images/select-provider.png index 3a280848c4..fcc362c12b 100644 Binary files a/docs/user-guide/providers/openstack/images/select-provider.png and b/docs/user-guide/providers/openstack/images/select-provider.png differ diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths-active-queries.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths-active-queries.mdx new file mode 100644 index 0000000000..d5ed09c60b --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-attack-paths-active-queries.mdx @@ -0,0 +1,41 @@ +--- +title: "Active Queries" +sidebarTitle: "Active Queries" +description: "Focus on the Attack Paths queries active in the environment: Prowler Cloud and Prowler Private Cloud record query results after each scan and hide confirmed-empty queries from the selector." +--- + +import { VersionBadge } from "/snippets/version-badge.mdx"; +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"; + + + + + +Active Queries extends the base [Attack Paths](/user-guide/tutorials/prowler-app-attack-paths) feature with capabilities that rely on managed scan infrastructure. This capability is available only in Prowler Cloud and Prowler Private Cloud. + +## Focusing on Queries with Data + +Running an Attack Paths query against a scan that contains no matching pattern returns an empty graph. Without automatic filtering, identifying the queries that apply to an account means opening each one and checking whether it produces a result. Running the RDS inventory query on an account with no RDS instances, for example, returns a "No data found" message. + +![Attack Paths query returning no data](/images/prowler-app/attack-paths/query-no-data.png) + +Prowler Cloud removes that trial and error. At the end of each scan, Prowler Cloud records which built-in queries returned data. The query selector then hides the queries confirmed empty for the selected scan, so only the queries that surface a real path remain visible. Following the example above, the RDS inventory query no longer appears in the selector. + +![Attack Paths query selector with confirmed-empty queries hidden](/images/prowler-app/attack-paths/query-selector-hidden-empty.png) + +A query stays available whenever its result is not a confirmed empty graph: + +- **Errored queries** remain listed. An error is not the same as an empty result and still requires investigation. +- **Unknown queries** remain listed. Their result for the scan has not been recorded yet. +- **Parameterized queries** remain listed. Their output depends on the input values provided at run time. + +## Browsing the Full Query Catalog on Prowler Hub + +The query selector shows the queries relevant to the selected scan, not the entire catalog. To review every built-in Attack Paths query, including the ones hidden for a given scan, browse the complete catalog on [Prowler Hub](https://hub.prowler.com). + +Prowler Hub lists each query with its name, description, and the technique it detects, so security teams can plan coverage and understand detection scope without running a scan first. + +## Related Pages + +- [Attack Paths](/user-guide/tutorials/prowler-app-attack-paths) - Run built-in and custom queries and explore the resulting graph. +- [Attack Paths Queries](/developer-guide/attack-paths-queries) - Write and maintain openCypher queries in the Developer Guide. diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index 22e56e5634..0242c817f5 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -1,5 +1,6 @@ --- title: "Attack Paths" +sidebarTitle: "Overview" description: "Identify privilege escalation chains and security misconfigurations across cloud environments using graph-based analysis." --- @@ -95,6 +96,12 @@ 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). + + ## Configuring Query Parameters Some queries accept optional or required parameters to narrow the scope of the analysis. When a query has parameters, a form appears below the query description. @@ -178,107 +185,11 @@ RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service LIMIT 25 ``` -### Working with List-Typed Properties +### Graph Schema and Advanced Patterns -Some Cartography node properties carry a list of values, such as `action`, `resource`, `notaction`, and `notresource` on `AWSPolicyStatement` nodes, the algorithms on `KMSKey`, the container-definition lists on `ECSContainerDefinition`, and many others. The Attack Paths graph models each such property as a set of child item nodes connected to the parent by a typed edge. To read the values, traverse the edge; the parent does not carry the list as a single field. +Custom queries traverse the same Cartography graph the built-in queries use. Node labels, relationships, and properties follow the upstream [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html), enriched by Prowler with `ProwlerFinding` nodes linked through `HAS_FINDING`, `Internet` exposure nodes, and list-typed properties such as `action` and `resource` modeled as child item nodes. -The naming convention for any list-typed property on a parent label is: - -- **Child label:** `Item`. Example: `AWSPolicyStatement.resource` resolves to `AWSPolicyStatementResourceItem`. -- **Edge type:** `HAS_`. Example: `resource` resolves to `HAS_RESOURCE`. -- **Child property:** `value` for scalar lists (one string per list element). List-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) carry the original dict keys as named fields on the child node. - -To express "at least one item in the list satisfies a predicate", traverse the `HAS_*` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`. `RETURN DISTINCT` collapses duplicate parent rows produced when multiple child items satisfy the filter: - -```cypher -MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) -MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) -WHERE toLower(a.value) STARTS WITH 's3:get' - OR toLower(a.value) STARTS WITH 's3:list' -RETURN DISTINCT stmt -LIMIT 25 -``` - -To check whether every item in the list satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached. This is the one case where the pattern-comprehension form is the right tool: - -```cypher -MATCH (stmt:AWSPolicyStatement) -WHERE size([ - (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) - WHERE NOT toLower(a.value) STARTS WITH 's3:' - | a - ]) = 0 - AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0 -RETURN stmt -LIMIT 25 -``` - -For the "is any item of this list a substring of a dynamic value" case, such as "does any resource pattern in this policy match a target role ARN", add the `HAS_*` traversal as its own `MATCH` and check the substring relationship between the item value and the dynamic node in `WHERE`: - -```cypher -MATCH (role:AWSRole) -WHERE role.name = 'Admin' -MATCH (principal:AWSPrincipal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'}) -MATCH (stmt)-[:HAS_RESOURCE]->(r:AWSPolicyStatementResourceItem) -WHERE r.value = '*' - OR r.value CONTAINS role.name - OR role.arn CONTAINS r.value -RETURN DISTINCT principal.arn AS principal, stmt, role -LIMIT 25 -``` - -To return the list of values directly, collect them from the child items: - -```cypher -MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) -OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) -RETURN stmt, collect(a.value) AS actions -LIMIT 25 -``` - -### Working with JSON-Encoded Properties - -Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. In the Attack Paths graph, object-typed properties are stored as JSON-encoded strings to keep the schema portable across graph backends. The value looks like: - -``` -'{"StringEquals":{"aws:SourceAccount":"123456789012"}}' -``` - -There is no JSON parser available at query time, so use `CONTAINS` for substring checks against keys or known values: - -```cypher -MATCH (stmt:AWSPolicyStatement) -WHERE stmt.effect = 'Allow' - AND stmt.condition CONTAINS '"aws:SourceAccount"' -RETURN stmt -LIMIT 25 -``` - -When a query needs to inspect the structured members of a condition (for example, evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values. - -### Tips for Writing Queries - -- Start small with `LIMIT` to inspect the shape of the data before broadening the pattern. -- Traverse `HAS_*` edges to reach list-typed property values (for example `action`, `resource`). The parent node does not carry the list as a single field; see [Working with List-Typed Properties](#working-with-list-typed-properties) for the patterns. -- On large scans, avoid broad disconnected patterns such as `MATCH (a:Label), (b:OtherLabel)`. Bind one side with a selective predicate first, and use `WITH DISTINCT` between expanding traversals when duplicates are possible. -- Use `RETURN` projections (`RETURN n.name, n.region`) instead of returning whole nodes to keep responses compact. -- Combine resource nodes with `ProwlerFinding` nodes via `HAS_FINDING` to correlate misconfigurations with the affected resources. -- When a query times out or returns no rows, simplify the pattern step by step until the first variant runs successfully, then add constraints back. - -### Cartography Schema Reference - -Attack Paths graphs are populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework. The node labels, relationship types, and properties available in custom queries follow the upstream Cartography schema for the corresponding provider. - -For the complete catalogue of node labels and relationships available in custom queries, refer to the official Cartography schema documentation: - -- **AWS:** [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html) - -In addition to the upstream schema, Prowler enriches the graph with: - -- **`ProwlerFinding`** nodes representing Prowler check results, linked to affected resources via `HAS_FINDING` relationships. -- **`Internet`** nodes used to model exposure paths from the public internet to internal resources. -- **List-typed properties** such as `action` or `resource` on `AWSPolicyStatement`, the algorithm lists on `KMSKey`, and similar lists on other node types are modeled as child item nodes linked by typed `HAS_*` edges. See [Working with List-Typed Properties](#working-with-list-typed-properties) for the read pattern. -- **Object-typed properties** such as `condition` on `AWSPolicyStatement` are stored as JSON-encoded strings. See [Working with JSON-Encoded Properties](#working-with-json-encoded-properties) for the read pattern. +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 diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index bb993942cd..4eafc6846b 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -194,20 +194,13 @@ To complete the Prowler Cloud configuration: #### Step 5: Save and Verify Configuration -Click the "Save" button to complete the setup. The "SAML Integration" card will now display an "Active" status, indicating the configuration is complete and enabled. +Click the "Save" button to complete the setup. The "SAML SSO Integration" card will now display an "Enabled" status, indicating the configuration is complete and enabled. -![Verify Integration Status](/images/prowler-app/saml/saml-step-4.png) - - -**IdP Configuration** - -The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. - - +![Verify Integration Status](/images/prowler-app/saml/saml-sso-enabled.png) ### Remove SAML Configuration SAML SSO can be disabled by removing the existing configuration from the integration panel. -![Remove SAML configuration](/images/prowler-app/saml/saml-step-remove.png) +![Remove SAML configuration](/images/prowler-app/saml/saml-sso-remove.png) ### IdP-Initiated SSO @@ -225,9 +218,9 @@ Users can also initiate the login process directly from Prowler's login page: 1. Navigate to the Prowler login page 2. Click "Continue with SAML SSO" - ![](/images/prowler-app/saml/saml-signin-1.png) + ![Prowler Cloud login page with the "Continue with SAML SSO" button](/images/prowler-app/saml/saml-signin-1.png) 3. Enter their email address from the configured domain - ![](/images/prowler-app/saml/saml-signin-2.png) + ![SAML SSO login form asking for the email address](/images/prowler-app/saml/saml-signin-2.png) 4. The system redirects users to the IdP for authentication 5. After successful authentication, users are returned to Prowler Cloud diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index 6c3131d136..4a0c92f12d 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -85,12 +85,12 @@ For detailed instructions on configuring credentials for each provider, refer to ## Step 5: Test Connection -After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler can successfully connect to your provider: +After adding your cloud account credentials, click the `Check connection` button to verify that Prowler can successfully connect to your provider: Test Connection ## Step 6: Scan Started -After successfully adding and testing your credentials, Prowler will start scanning your cloud environment, click the `Go to Scans` button to see the progress: +After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress: Start Now @@ -127,20 +127,14 @@ While the scan is running, start exploring the findings in these sections: Compliance -- **Issues**: Types of issues detected. - - Issues - -- **Browse All Findings**: Detailed list of findings detected, where you can filter by severity, service, and more. +- **Findings**: Detailed list of findings detected, where you can filter by severity, service, and more. Findings To view all `new` findings that have not been seen prior to this scan, click the `Delta` filter and select `new`. To view all `changed` findings that have had a status change (from `PASS` to `FAIL` for example), click the `Delta` filter and select `changed`. ## Step 9: Download the Outputs -Once a scan is complete, navigate to the Scan Jobs section to download the output files generated by Prowler: - -Scan Jobs section +Once a scan is complete, navigate to the `Scans` section to download the output files generated by Prowler: You can download the output files generated by Prowler as a single `zip` file. This archive contains the CSV, JSON-OSCF, and HTML reports detailing the findings. @@ -163,8 +157,6 @@ Once your scan has finished, you don’t need to grab the entire ZIP—just pull - Navigate to the **Compliance** section of the UI. -Compliance section - - Find the Framework report you need. - Click its **Download** icon to retrieve that report’s CSV file with all the detailed findings. diff --git a/mcp_server/README.md b/mcp_server/README.md index c0e4fdb0fe..48ed370dcf 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -16,6 +16,7 @@ Full access to your Prowler data (Prowler Cloud, Prowler Private Cloud, or Prowl - **Resource Inventory**: Search and view detailed information about your audited resources - **Muting Management**: Create and manage muting rules to suppress non-critical findings - **Compliance Reporting**: View compliance status across frameworks and drill into requirement-level details +- **User & Role Management**: List the users in your tenant, identify the authenticated user, browse RBAC roles, and set the role a user holds ### Prowler Hub diff --git a/mcp_server/changelog.d/roles-tools.added.md b/mcp_server/changelog.d/roles-tools.added.md new file mode 100644 index 0000000000..f06343dfb0 --- /dev/null +++ b/mcp_server/changelog.d/roles-tools.added.md @@ -0,0 +1 @@ +RBAC role tools `prowler_list_roles`, `prowler_get_role`, `prowler_get_user_roles`, and `prowler_set_user_role` for browsing roles and setting the role a user holds diff --git a/mcp_server/changelog.d/users-read-tools.added.md b/mcp_server/changelog.d/users-read-tools.added.md new file mode 100644 index 0000000000..3bc55952d8 --- /dev/null +++ b/mcp_server/changelog.d/users-read-tools.added.md @@ -0,0 +1 @@ +Read-only user management tools `prowler_list_users`, `prowler_get_user`, and `prowler_get_current_user` for listing tenant users with their emails and identifying the authenticated user diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/roles.py b/mcp_server/prowler_mcp_server/prowler_app/models/roles.py new file mode 100644 index 0000000000..91499243c5 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/models/roles.py @@ -0,0 +1,230 @@ +"""Data models for Prowler RBAC roles. + +This module provides Pydantic models for representing Prowler roles with +two-tier complexity: +- SimplifiedRole: For list operations with essential identification fields +- DetailedRole: Extends simplified with the capabilities the role grants and + its related users / provider groups + +It also provides UserRolesResult, used by the tools that read or change the +roles assigned to a specific user. + +All models inherit from MinimalSerializerMixin to exclude None/empty values +for optimal LLM token usage. +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin +from prowler_mcp_server.prowler_app.models.utils import extract_relationship_ids + +# Role capabilities are exposed by the API as boolean "manage_*" attributes. +_PERMISSION_ATTRIBUTE_PREFIX = "manage_" + + +class SimplifiedRole(MinimalSerializerMixin, BaseModel): + """Simplified role representation for list operations. + + Includes core identification fields for efficient overview. + Used by list_roles() tool. + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field( + description="Unique UUIDv4 identifier for this role in Prowler database" + ) + name: str = Field(description="Human-readable name of the role") + permission_state: str | None = Field( + default=None, + description="Summary of the role's permissions: 'unlimited' (all), 'limited' (some), or 'none'", + ) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "SimplifiedRole": + """Transform a JSON:API role resource into a simplified model. + + Args: + data: Role data from API response['data'] (single item or list item) + + Returns: + SimplifiedRole instance + """ + attributes = data["attributes"] + + return cls( + id=data["id"], + name=attributes["name"], + permission_state=attributes.get("permission_state"), + ) + + +class DetailedRole(SimplifiedRole): + """Detailed role representation with granted capabilities and relationships. + + Extends SimplifiedRole with the concrete management capabilities the role + grants, its visibility scope, and the IDs of related users and provider + groups. Used by get_role(), get_user_roles() and set_user_role(). + """ + + model_config = ConfigDict(frozen=True) + + permissions: list[str] | None = Field( + default=None, + description="Management capabilities granted by this role, as reported by the API (only the enabled ones), e.g. ['manage_users', 'manage_scans']. Deployments do not all expose the same capabilities, so read `permission_state` for the authoritative summary: 'unlimited' means the role grants every capability, including any not listed here.", + ) + unlimited_visibility: bool | None = Field( + default=None, + description="Whether the role can see all providers (True) or only those in its provider groups (False)", + ) + provider_group_ids: list[str] | None = Field( + default=None, + description="UUIDv4 identifiers of the provider groups this role is scoped to. An empty list means the role is not scoped to any provider group.", + ) + user_ids: list[str] | None = Field( + default=None, + description="UUIDv4 identifiers of the users this role is assigned to. An empty list means the role is not assigned to any user.", + ) + inserted_at: str | None = Field( + default=None, description="ISO 8601 timestamp when the role was created" + ) + updated_at: str | None = Field( + default=None, description="ISO 8601 timestamp when the role was last modified" + ) + + def _should_exclude(self, key: str, value: Any) -> bool: + """Keep fields whose "empty" form carries meaning. + + ``unlimited_visibility`` is kept even when ``False``, and ``permissions`` + and the relationship lists are kept even when empty so that an empty + ``permissions``/``user_ids``/``provider_group_ids`` explicitly signals + "grants no capabilities / not assigned to any user / not scoped to any + provider group" instead of looking like an omitted, unknown field to an + agent. + """ + if key in ( + "unlimited_visibility", + "permissions", + "user_ids", + "provider_group_ids", + ): + return value is None + return super()._should_exclude(key, value) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "DetailedRole": + """Transform a JSON:API role resource into a detailed model. + + Args: + data: Role data from API response['data'] or an included role + + Returns: + DetailedRole instance with all fields populated + """ + attributes = data["attributes"] + relationships = data.get("relationships", {}) + + permissions = [ + name + for name, enabled in attributes.items() + if name.startswith(_PERMISSION_ATTRIBUTE_PREFIX) and enabled + ] + + return cls( + id=data["id"], + name=attributes["name"], + permission_state=attributes.get("permission_state"), + permissions=permissions, + unlimited_visibility=attributes.get("unlimited_visibility"), + provider_group_ids=extract_relationship_ids( + relationships, "provider_groups" + ), + user_ids=extract_relationship_ids(relationships, "users"), + inserted_at=attributes.get("inserted_at"), + updated_at=attributes.get("updated_at"), + ) + + +class RolesListResponse(BaseModel): + """Response model for list_roles() with pagination metadata. + + Follows the established pattern from ScansListResponse and UsersListResponse. + """ + + roles: list[SimplifiedRole] + total_num_roles: int + total_num_pages: int + current_page: int + + @classmethod + def from_api_response(cls, response: dict[str, Any]) -> "RolesListResponse": + """Transform a JSON:API list response into a roles list with pagination. + + Args: + response: Full API response with data and meta + + Returns: + RolesListResponse with simplified roles and pagination metadata + """ + data = response.get("data", []) + meta = response.get("meta", {}) + pagination = meta.get("pagination", {}) + + roles = [SimplifiedRole.from_api_response(item) for item in data] + + return cls( + roles=roles, + total_num_roles=pagination.get("count", 0), + total_num_pages=pagination.get("pages", 0), + current_page=pagination.get("page", 1), + ) + + +class UserRolesResult(MinimalSerializerMixin, BaseModel): + """The roles currently assigned to a user. + + Used by get_user_roles() to report a user's roles, and by set_user_role() + to report the authoritative role set after a change (with `changed` and + `message` describing the outcome). + """ + + user_id: str = Field(description="UUIDv4 identifier of the user") + total_num_roles: int = Field( + description="Number of roles currently assigned to the user" + ) + roles: list[DetailedRole] = Field( + description="The roles currently assigned to the user, with their granted capabilities" + ) + changed: bool | None = Field( + default=None, + description="For assignment operations: whether this call actually modified the user's roles", + ) + message: str | None = Field( + default=None, + description="For assignment operations: human-readable description of the outcome", + ) + + def _should_exclude(self, key: str, value: Any) -> bool: + """Always include the roles list, even when empty (explicit 'no roles').""" + if key == "roles": + return False + return super()._should_exclude(key, value) + + @classmethod + def build( + cls, + user_id: str, + roles: list[DetailedRole], + changed: bool | None = None, + message: str | None = None, + ) -> "UserRolesResult": + """Assemble a result from a user's role list, filling the count.""" + return cls( + user_id=user_id, + total_num_roles=len(roles), + roles=roles, + changed=changed, + message=message, + ) diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/users.py b/mcp_server/prowler_mcp_server/prowler_app/models/users.py new file mode 100644 index 0000000000..0dcd4ac501 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/models/users.py @@ -0,0 +1,143 @@ +"""Data models for Prowler users. + +This module provides Pydantic models for representing Prowler users with +two-tier complexity: +- SimplifiedUser: For list operations with essential identification fields +- DetailedUser: Extends simplified with account metadata and role/membership links + +All models inherit from MinimalSerializerMixin to exclude None/empty values +for optimal LLM token usage. +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin +from prowler_mcp_server.prowler_app.models.utils import extract_relationship_ids + + +class SimplifiedUser(MinimalSerializerMixin, BaseModel): + """Simplified user representation for list operations. + + Includes core identification fields for efficient overview. + Used by list_users() tool. + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field( + description="Unique UUIDv4 identifier for this user in Prowler database" + ) + name: str = Field(description="Display name of the user") + email: str = Field(description="Email address of the user") + company_name: str | None = Field( + default=None, description="Company the user belongs to, if provided" + ) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "SimplifiedUser": + """Transform a JSON:API user resource into a simplified model. + + Args: + data: User data from API response['data'] (single item or list item) + + Returns: + SimplifiedUser instance + """ + attributes = data["attributes"] + + return cls( + id=data["id"], + name=attributes["name"], + email=attributes["email"], + company_name=attributes.get("company_name"), + ) + + +class DetailedUser(SimplifiedUser): + """Detailed user representation with account metadata and relationships. + + Extends SimplifiedUser with the join date and the IDs of the roles and + memberships associated with the user. + Used by get_user() and get_current_user() tools. + + Note: ``role_ids`` and ``membership_ids`` are omitted when empty because an + empty list is ambiguous here. The API hides another user's roles/memberships + from callers without MANAGE_ACCOUNT (returning them empty rather than + forbidden), so an empty list cannot be told apart from "genuinely none". They + are only reported when at least one ID is visible. + """ + + model_config = ConfigDict(frozen=True) + + date_joined: str | None = Field( + default=None, + description="ISO 8601 timestamp when the user joined", + ) + role_ids: list[str] | None = Field( + default=None, + description="UUIDv4 identifiers of the roles assigned to the user (omitted when none are visible)", + ) + membership_ids: list[str] | None = Field( + default=None, + description="UUIDv4 identifiers of the tenant memberships of the user (omitted when none are visible)", + ) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "DetailedUser": + """Transform a JSON:API user resource into a detailed model. + + Args: + data: User data from API response['data'] + + Returns: + DetailedUser instance with all fields populated + """ + attributes = data["attributes"] + relationships = data.get("relationships", {}) + + return cls( + id=data["id"], + name=attributes["name"], + email=attributes["email"], + company_name=attributes.get("company_name"), + date_joined=attributes.get("date_joined"), + role_ids=extract_relationship_ids(relationships, "roles"), + membership_ids=extract_relationship_ids(relationships, "memberships"), + ) + + +class UsersListResponse(BaseModel): + """Response model for list_users() with pagination metadata. + + Follows the established pattern from ScansListResponse and ProvidersListResponse. + """ + + users: list[SimplifiedUser] + total_num_users: int + total_num_pages: int + current_page: int + + @classmethod + def from_api_response(cls, response: dict[str, Any]) -> "UsersListResponse": + """Transform a JSON:API list response into a users list with pagination. + + Args: + response: Full API response with data and meta + + Returns: + UsersListResponse with simplified users and pagination metadata + """ + data = response.get("data", []) + meta = response.get("meta", {}) + pagination = meta.get("pagination", {}) + + users = [SimplifiedUser.from_api_response(item) for item in data] + + return cls( + users=users, + total_num_users=pagination.get("count", 0), + total_num_pages=pagination.get("pages", 0), + current_page=pagination.get("page", 1), + ) diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/utils.py b/mcp_server/prowler_mcp_server/prowler_app/models/utils.py new file mode 100644 index 0000000000..a20687c03a --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/models/utils.py @@ -0,0 +1,45 @@ +"""Shared helpers for building models from Prowler API responses. + +Stateless utilities used by the models' ``from_api_response()`` factory methods +to read the JSON:API document structure (relationships, linkage, etc.). Keeping +them here leaves ``base.py`` focused on the base model/mixin and gives these +response-parsing helpers a single, discoverable home. +""" + +from typing import Any + + +def extract_relationship_ids( + relationships: dict[str, Any], relationship_name: str +) -> list[str] | None: + """Extract related resource IDs from a JSON:API relationship. + + Handles both to-one (``data`` is an object) and to-many (``data`` is a list) + relationships, returning a flat list of IDs in either case. + + The absent and present-but-empty cases are deliberately distinguished so + callers can tell "the relationship was not part of this document" from "the + relationship is genuinely empty": + + - Relationship key absent → ``None`` (unknown; the serializer did not expose + it, e.g. a role included via ``?include=roles`` carries no ``users``). + - Relationship key present but with no members → ``[]`` (explicitly none). + + Args: + relationships: The ``relationships`` object from a JSON:API resource + relationship_name: The relationship key to read (e.g. ``"roles"``) + + Returns: + List of related resource IDs, ``[]`` if the relationship is present but + empty, or ``None`` if the relationship is absent from the document. + """ + relationship = relationships.get(relationship_name) + if relationship is None: + return None + data = relationship.get("data") + if not data: + return [] + if isinstance(data, list): + return [item["id"] for item in data if item and item.get("id")] + # to-one relationship + return [data["id"]] if data.get("id") else [] diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py new file mode 100644 index 0000000000..113694d8e8 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py @@ -0,0 +1,222 @@ +"""Role (RBAC) tools for Prowler MCP Server. + +This module provides read tools for browsing roles and inspecting the role a +user holds, plus a tool for setting it. + +A user holds exactly one role: the API resolves a user's permissions from a +single role (`get_role` in `api.rbac.permissions`) and the UI only ever assigns +one, so setting a role replaces the one the user currently holds instead of +adding to it. +""" + +from typing import Any + +from pydantic import Field + +from prowler_mcp_server.prowler_app.models.roles import ( + DetailedRole, + RolesListResponse, + UserRolesResult, +) +from prowler_mcp_server.prowler_app.tools.base import BaseTool + + +class RolesTools(BaseTool): + """Tools for RBAC role operations. + + Provides tools for: + - prowler_list_roles: List the roles defined in the tenant + - prowler_get_role: Get detailed information about a specific role by ID + - prowler_get_user_roles: List the roles assigned to a specific user + - prowler_set_user_role: Set the role a user holds (idempotent) + """ + + async def list_roles( + self, + page_size: int = Field( + default=50, description="Number of results to return per page" + ), + page_number: int = Field( + default=1, description="Page number to retrieve (1-indexed)" + ), + ) -> dict[str, Any]: + """List the RBAC roles defined in the authenticated tenant. + + Use this to discover which roles exist and their permission scope before + assigning one to a user. Returns LIGHTWEIGHT role information. + + Each role includes: + - id: Prowler internal UUID (v4), used with `prowler_get_role` and the assignment tools + - name: Human-readable role name + - permission_state: Summary of what the role grants ('unlimited', 'limited' or 'none') + + For the concrete capabilities a role grants and the users/provider groups + it relates to, use `prowler_get_role`. + """ + self.api_client.validate_page_size(page_size) + + params: dict[str, Any] = { + "fields[roles]": "name,permission_state", + "page[number]": page_number, + "page[size]": page_size, + } + + clean_params = self.api_client.build_filter_params(params) + + api_response = await self.api_client.get("/roles", params=clean_params) + simplified_response = RolesListResponse.from_api_response(api_response) + + return simplified_response.model_dump() + + async def get_role( + self, + role_id: str = Field( + description="Prowler's internal UUID (v4) for the role to retrieve. Use `prowler_list_roles` to find role IDs if you only know a name." + ), + ) -> dict[str, Any]: + """Retrieve detailed information about a specific role by its ID. + + Returns everything `prowler_list_roles` returns PLUS: + - permissions: The management capabilities the role grants (only the enabled ones). Read `permission_state` for the authoritative summary: 'unlimited' means the role grants every capability, including any this deployment does not list individually + + - unlimited_visibility: Whether the role can see all providers or only its provider groups + - provider_group_ids: Provider groups the role is scoped to (empty list means it is scoped to no provider group) + - user_ids: Users the role is assigned to (empty list means it is assigned to no user) + - inserted_at / updated_at: Lifecycle timestamps + + The `user_ids` and `provider_group_ids` fields are always present: an + empty list means "none", not "unknown". + + Workflow: + 1. Use `prowler_list_roles` to browse roles and find the target role 'id' + 2. Use this tool with that 'id' to inspect exactly what the role grants + """ + api_response = await self.api_client.get(f"/roles/{role_id}") + detailed_role = DetailedRole.from_api_response(api_response["data"]) + + return detailed_role.model_dump() + + async def get_user_roles( + self, + user_id: str = Field( + description="Prowler's internal UUID (v4) for the user whose roles you want. Use `prowler_list_users` to find user IDs, or `prowler_get_current_user` for the caller." + ), + ) -> dict[str, Any]: + """List the roles currently assigned to a specific user. + + Returns the user's roles with the concrete capabilities each one grants, + so you can see what the user is allowed to do in the tenant. A user + normally holds a single role. + + Note: this reads the user's record, so it requires MANAGE_USERS (the same + permission `prowler_get_user` needs). Each role's `user_ids` and + `provider_group_ids` are not resolved here; use `prowler_get_role` for a + role's full assignment and provider-group scope. + + Workflow: + 1. Use `prowler_list_users` (or `prowler_get_current_user`) to find the user 'id' + 2. Use this tool to see which role they hold and what it grants + 3. Use `prowler_set_user_role` to change it + """ + roles = await self._fetch_user_roles(user_id) + + return UserRolesResult.build(user_id=user_id, roles=roles).model_dump() + + async def set_user_role( + self, + user_id: str = Field( + description="Prowler's internal UUID (v4) for the user whose role you want to set. Use `prowler_list_users` to find user IDs." + ), + role_id: str = Field( + description="Prowler's internal UUID (v4) for the role the user should hold. Use `prowler_list_roles` to find role IDs." + ), + ) -> dict[str, Any]: + """Set the role a user holds, replacing the role they had before. + + A user holds exactly one role in Prowler: their permissions are resolved + from a single role, so granting a new one REPLACES the previous one + instead of adding to it. To change what a user can do, set the role that + grants the capabilities they should have. + + This tool is idempotent: if the user already holds only this role, it + makes no change and reports `changed: false`. It always returns the + user's up-to-date role after the operation. + + Note: this operation requires both MANAGE_ACCOUNT (to change role + assignments) and MANAGE_USERS (to read the user's current role). The API + rejects the change when it would leave the tenant without a user holding + MANAGE_ACCOUNT; such rejections are surfaced as errors. + + Workflow: + 1. Use `prowler_list_roles` to find the role 'id' to grant + 2. Use `prowler_list_users` to find the target user 'id' + 3. Use this tool to set the user's role + """ + current_roles = await self._fetch_user_roles(user_id) + if [role.id for role in current_roles] == [role_id]: + return UserRolesResult.build( + user_id=user_id, + roles=current_roles, + changed=False, + message=f"User {user_id} already holds role {role_id}; no change made.", + ).model_dump() + + # The relationship endpoint accepts any well-formed UUID and silently + # drops role IDs that do not exist in this tenant, which would leave the + # user with no role at all. Confirm the role exists before replacing. + try: + await self.api_client.get(f"/roles/{role_id}") + except Exception as e: + raise ValueError( + f"Role {role_id} could not be read ({e}), so user {user_id} was left " + f"unchanged. Use `prowler_list_roles` to find a valid role ID." + ) from e + + # PATCH replaces the user's whole role set with this single role, the + # same call the Prowler UI makes when changing a user's role. + await self.api_client.patch( + f"/users/{user_id}/relationships/roles", + json_data={"data": [{"type": "roles", "id": role_id}]}, + ) + + # After the change, fetch the user's roles again to report the authoritative state + updated_roles = await self._fetch_user_roles(user_id) + + return UserRolesResult.build( + user_id=user_id, + roles=updated_roles, + changed=True, + message=f"Role {role_id} set for user {user_id}.", + ).model_dump() + + # Private helper methods + + async def _fetch_user_roles(self, user_id: str) -> list[DetailedRole]: + """Fetch the roles currently assigned to a user. + + Uses a single `GET /users/{id}?include=roles` request and reads the + role resources from the JSON:API `included` section. This request + requires MANAGE_USERS (it reads the user record). + + The included role resources do not carry their `users` / + `provider_groups` relationships, so the returned `DetailedRole` + instances omit `user_ids` / `provider_group_ids` (unknown here) + rather than reporting them as empty. Use `get_role` for a role's full + assignment and provider-group scope. + + Args: + user_id: The Prowler UUID of the user + + Returns: + The user's roles as DetailedRole instances (empty list if none) + """ + response = await self.api_client.get( + f"/users/{user_id}", params={"include": "roles"} + ) + included = response.get("included", []) or [] + + return [ + DetailedRole.from_api_response(item) + for item in included + if item.get("type") == "roles" + ] diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/users.py b/mcp_server/prowler_mcp_server/prowler_app/tools/users.py new file mode 100644 index 0000000000..a7e31b60ad --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/users.py @@ -0,0 +1,121 @@ +"""User management tools for Prowler MCP Server. + +This module provides read-only tools for viewing the users that belong to the +authenticated tenant, including identifying which user the current credentials +(API key or JWT) authenticate as. +""" + +from typing import Any + +from pydantic import Field + +from prowler_mcp_server.prowler_app.models.users import ( + DetailedUser, + UsersListResponse, +) +from prowler_mcp_server.prowler_app.tools.base import BaseTool + + +class UsersTools(BaseTool): + """Tools for user management operations (read-only). + + Provides tools for: + - prowler_list_users: List the users in the tenant with their names and emails + - prowler_get_user: Get detailed information about a specific user by ID + - prowler_get_current_user: Identify which user the current credentials authenticate as + """ + + async def list_users( + self, + name: str | None = Field( + default=None, + description="Filter by user display name. Partial match supported (case-insensitive).", + ), + email: str | None = Field( + default=None, + description="Filter by user email address. Partial match supported (case-insensitive).", + ), + page_size: int = Field( + default=50, description="Number of results to return per page" + ), + page_number: int = Field( + default=1, description="Page number to retrieve (1-indexed)" + ), + ) -> dict[str, Any]: + """List the users that belong to the authenticated tenant. + + Use this to see who has access to the tenant and to look up their email + addresses. Returns LIGHTWEIGHT user information optimized for browsing. + + Each user includes: + - id: Prowler internal UUID (v4), used with `prowler_get_user` + - name: Display name + - email: Email address + - company_name: Company the user belongs to, when set + + To find out which user the current credentials authenticate as, use + `prowler_get_current_user`. For a single user's roles, membership links + and join date, use `prowler_get_user`. + """ + self.api_client.validate_page_size(page_size) + + params: dict[str, Any] = { + "fields[users]": "name,email,company_name", + "page[number]": page_number, + "page[size]": page_size, + } + + if name: + params["filter[name__icontains]"] = name + if email: + params["filter[email__icontains]"] = email + + clean_params = self.api_client.build_filter_params(params) + + api_response = await self.api_client.get("/users", params=clean_params) + simplified_response = UsersListResponse.from_api_response(api_response) + + return simplified_response.model_dump() + + async def get_user( + self, + user_id: str = Field( + description="Prowler's internal UUID (v4) for the user to retrieve. Use `prowler_list_users` to find user IDs if you only know a name or email." + ), + ) -> dict[str, Any]: + """Retrieve detailed information about a specific user by their ID. + + Returns everything `prowler_list_users` returns PLUS: + - date_joined: When the user joined + - role_ids: UUIDs of the roles assigned to the user + - membership_ids: UUIDs of the user's tenant memberships + + Reading another user's roles/memberships requires MANAGE_ACCOUNT; without + it the API hides them and `role_ids`/`membership_ids` are omitted rather + than reported as empty. + + Workflow: + 1. Use `prowler_list_users` to browse users and find the target user 'id' + 2. Use this tool with that 'id' to inspect the user's roles and account details + """ + api_response = await self.api_client.get(f"/users/{user_id}") + detailed_user = DetailedUser.from_api_response(api_response["data"]) + + return detailed_user.model_dump() + + async def get_current_user(self) -> dict[str, Any]: + """Identify which user the current credentials authenticate as. + + Use this to determine the identity behind the credentials this MCP server + is currently using, e.g. before performing actions on behalf of that user + or when reporting who is connected. + + Returns the same detailed information as `prowler_get_user`: + - id, name, email, company_name + - date_joined + - role_ids, membership_ids + """ + api_response = await self.api_client.get("/users/me") + detailed_user = DetailedUser.from_api_response(api_response["data"]) + + return detailed_user.model_dump() diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py index 187364bee1..3222454dcb 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py @@ -176,13 +176,19 @@ class ProwlerAPIClient(metaclass=SingletonMeta): ) async def delete( - self, path: str, params: dict[str, any] | None = None + self, + path: str, + params: dict[str, any] | None = None, + json_data: dict[str, any] | None = None, ) -> dict[str, any]: """Make DELETE request. Args: path: API endpoint path params: Optional query parameters + json_data: Optional JSON body data. Some JSON:API relationship + endpoints (e.g. ``/users/{id}/relationships/roles``) accept a + body listing the specific members to remove. Returns: API response as dictionary @@ -190,7 +196,9 @@ class ProwlerAPIClient(metaclass=SingletonMeta): Raises: Exception: If API request fails """ - return await self._make_request(HTTPMethod.DELETE, path, params=params) + return await self._make_request( + HTTPMethod.DELETE, path, params=params, json_data=json_data + ) async def fetch_external_url(self, url: str) -> str: """Fetch content from an allowed external URL (unauthenticated). diff --git a/prowler/changelog.d/gcp-cloudfunction-gen2-iam-thread-safety.fixed.md b/prowler/changelog.d/gcp-cloudfunction-gen2-iam-thread-safety.fixed.md new file mode 100644 index 0000000000..f5ec21ab0b --- /dev/null +++ b/prowler/changelog.d/gcp-cloudfunction-gen2-iam-thread-safety.fixed.md @@ -0,0 +1 @@ +GCP Cloud Functions gen2 IAM policy retrieval now uses a per-request HTTP client, preventing a process crash from concurrent thread-unsafe `httplib2` access when a project has several gen2 functions diff --git a/prowler/changelog.d/jira-adf-metadata.fixed.md b/prowler/changelog.d/jira-adf-metadata.fixed.md new file mode 100644 index 0000000000..c227c4a473 --- /dev/null +++ b/prowler/changelog.d/jira-adf-metadata.fixed.md @@ -0,0 +1 @@ +Jira descriptions with inline code nested in bold or italic Markdown now render as valid ADF diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 601120cdfc..9fb4c7d6ad 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -203,7 +203,9 @@ class MarkdownToADFConverter: if token_type == "text": result.extend(self._text_to_nodes(token.content, marks_stack)) elif token_type == "code_inline": - marks = self._clone_marks(marks_stack) + marks = self._clone_marks( + [mark for mark in marks_stack if mark["type"] == "link"] + ) marks.append({"type": "code"}) result.append(self._create_text_node(token.content, marks)) elif token_type in {"softbreak", "hardbreak"}: diff --git a/prowler/lib/outputs/ocsf/ocsf.py b/prowler/lib/outputs/ocsf/ocsf.py index 29898ce813..6d271d9bce 100644 --- a/prowler/lib/outputs/ocsf/ocsf.py +++ b/prowler/lib/outputs/ocsf/ocsf.py @@ -1,8 +1,9 @@ import json import os from datetime import datetime, timezone +from functools import lru_cache from random import getrandbits -from typing import List, Optional +from typing import Dict, List, Optional from py_ocsf_models.events.base_event import SeverityID, StatusID from py_ocsf_models.events.findings.detection_finding import ( @@ -345,36 +346,68 @@ def _build_analytic(finding: Finding) -> Analytic: ) +@lru_cache(maxsize=None) +def _load_mitre_technique_map(provider: str) -> Dict[str, dict]: + """Load and cache MITRE ATT&CK techniques for a provider.""" + try: + mitre_file = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "compliance", + provider, + f"mitre_attack_{provider}.json", + ) + with open(mitre_file) as file: + data = json.load(file) + return { + requirement["Id"]: requirement + for requirement in data.get("Requirements", []) + } + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + def _build_mitre_attacks(finding: Finding) -> Optional[List[MITREAttack]]: - """Build OCSF MITREAttack objects from MITRE-ATTACK metadata. + """Build OCSF MITREAttack objects from finding compliance technique IDs. Args: - finding (Finding): Finding with compliance metadata attached to its check metadata. + finding (Finding): Finding with MITRE ATT&CK compliance technique IDs. Returns: - Optional[List[MITREAttack]]: MITRE attacks derived from metadata, or None - when the finding has no MITRE-ATTACK metadata. + Optional[List[MITREAttack]]: MITRE attacks for known provider techniques, + or None when none can be built. """ + technique_map = _load_mitre_technique_map(finding.provider) attacks = [] - for compliance in finding.metadata.Compliance or []: - if compliance.Framework.upper() != "MITRE-ATTACK": + for technique_id in finding.compliance.get("MITRE-ATTACK", []): + requirement = technique_map.get(technique_id) + if not requirement: continue - - for requirement in compliance.Requirements: - technique = Technique( - uid=requirement.Id, - name=requirement.Name, - src_url=requirement.TechniqueURL, + technique_name = requirement.get("Name") + if not technique_name: + logger.warning( + f"Skipping MITRE ATT&CK technique {technique_id} for provider {finding.provider}: missing Name" ) - for tactic_name in requirement.Tactics: - attacks.append( - MITREAttack( - technique=technique, - tactic=Tactic(name=tactic_name), - ) + continue + technique = Technique( + uid=technique_id, + name=technique_name, + src_url=requirement.get("TechniqueURL"), + ) + for tactic_name in requirement.get("Tactics", []): + attacks.append( + MITREAttack( + technique=technique, + tactic=Tactic(name=tactic_name), ) + ) - return attacks if attacks else None + return attacks or None # NOTE: Copied from api/src/backend/api/uuid_utils.py (datetime_to_uuid7) diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py index 9905c98748..9808f63ff8 100644 --- a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py @@ -108,7 +108,10 @@ class CloudFunction(GCPService): .locations() .services() .getIamPolicy(resource=function.service) - .execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + .execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) ) else: response = ( diff --git a/skills/prowler-attack-paths-query/SKILL.md b/skills/prowler-attack-paths-query/SKILL.md index a70734aa6f..12bac88770 100644 --- a/skills/prowler-attack-paths-query/SKILL.md +++ b/skills/prowler-attack-paths-query/SKILL.md @@ -9,7 +9,7 @@ description: > license: Apache-2.0 metadata: author: prowler-cloud - version: "3.0" + version: "3.1" scope: [root, api] auto_invoke: - "Creating Attack Paths queries" @@ -22,6 +22,8 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, Task Attack Paths queries are read-only openCypher queries over a Cartography-ingested cloud graph that detect privilege escalation chains, network exposure, and other graph-shaped security risks. Queries are written in openCypher Version 9 so they run on both Neo4j and Amazon Neptune sinks. +This skill is the concise, action-oriented reference for building queries. For the complete human-readable reference (graph model, list-typed and JSON-encoded properties, compatibility, and worked examples), see `docs/developer-guide/attack-paths-queries.mdx`. + --- ## Two query audiences @@ -126,12 +128,16 @@ AWS_{QUERY_NAME} = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Target resources attached to the same principal (sub-patterns below) - MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) - WHERE target_policy.arn CONTAINS $provider_uid + // Pre-aggregate the statement's resource values (see "Avoiding cartesian products") MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR target_policy.arn CONTAINS res.value + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target policies attached to the principal, matched once against the resource list + MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) + WITH path_principal, path_target, res_values, res_wildcard, target_policy.arn AS parn + WHERE parn CONTAINS $provider_uid + AND (res_wildcard OR size([rv IN res_values WHERE parn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -160,6 +166,33 @@ Key points: --- +## Avoiding cartesian products + +Matching a target set (`AWSRole`, `AWSUser`, `AWSGroup`) and then filtering each target against a statement's `HAS_RESOURCE` items in a separate, unconnected `MATCH` builds a cartesian product: every target is paired with every resource item before the filter runs. On accounts with many principals this errors or times out. Pre-aggregate the resource values into a list, then match each target once: + +```cypher +// Pre-aggregate the statement's resource values into a list +MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) +WITH aws, path_principal, collect(DISTINCT res.value) AS res_values +WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + +// Match each target once; bind name/arn to locals so the predicate reads them once +MATCH path_target = (aws)--(target_role:AWSRole) +WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn +WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 +``` + +- Aggregate resources before matching targets; cost becomes `targets + resources`, not `targets × resources`. This is a pure rewrite, the result set is identical. +- `('*' IN res_values)` short-circuits the wildcard grant so the list scan runs only when needed. +- Bind `target.name` / `target.arn` to locals so the list comprehension reads them once per target, not once per resource value. +- `size([...]) > 0` is the Neptune-compatible form of `any()` (see "openCypher compatibility"). +- Two-statement queries aggregate each statement's resources into its own list (`res_values`, `res2_values`) and combine the two `size([...]) > 0` checks with `AND`. +- Targets already constrained by a relationship (`STS_ASSUMEROLE_ALLOW`, `TRUSTS_AWS_PRINCIPAL`) need no aggregation: the relationship already bounds the set. + +--- + ## Privilege escalation sub-patterns Four `path_target` shapes cover the common attack types. Each shares the canonical template's `path_principal`, deduplication tail, and `RETURN`; only the `path_target` MATCH and its resource predicate differ. @@ -273,17 +306,9 @@ The literal-action list is case-folded with `toLower(act.value)` because IAM aut ### Example - resource ARN match -Find statements whose resource can target a specific role: +To find statements whose resource can target a specific role, pre-aggregate the resource values and test the target against the list once (see "Avoiding cartesian products"). Do not pair the target set with the `HAS_RESOURCE` items in a separate `MATCH`; that builds a cartesian product. -```cypher -MATCH path_target = (aws)--(target_role:AWSRole) -MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) -WHERE res.value = '*' - OR res.value CONTAINS target_role.name - OR target_role.arn CONTAINS res.value -``` - -Three predicates cover the cases: full wildcard (`*`), pattern containing the role name (`arn:aws:iam::*:role/admin*`), and pattern that is a prefix or component of the actual ARN. +Three predicates cover the resource cases: full wildcard (`*`), a pattern containing the target name (`arn:aws:iam::*:role/admin*`), and a pattern that is a prefix or component of the actual ARN. ### Catalog of list properties @@ -293,25 +318,7 @@ The provider catalog lives in `api/src/backend/tasks/jobs/attack_paths/provider_ ## Common openCypher patterns -### Match account and principal - -```cypher -MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'}) -``` - -The `(aws)--(principal)` hop stays anonymous; the `POLICY` and `STATEMENT` hops are typed. - -### Roles trusting a service - -```cypher -MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) -``` - -### Roles a principal can assume - -```cypher -MATCH path_target = (aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal) -``` +The account/principal match and the service-trust and assume-role target shapes appear in the template and sub-patterns above. Additional reusable patterns: ### JSON-encoded properties diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index f38c1ba376..9abd2d26a1 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -23,11 +23,55 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraSendFindingsResponseError, JiraTestConnectionError, ) -from prowler.lib.outputs.jira.jira import Jira +from prowler.lib.outputs.jira.jira import Jira, MarkdownToADFConverter TEST_DATETIME = "2023-01-01T12:01:01+00:00" +class TestMarkdownToADFConverter: + def setup_method(self): + self.converter = MarkdownToADFConverter() + + def test_inline_code_nested_in_strong_has_only_code_mark(self): + result = self.converter.convert("**before `code` after**") + + assert result[0]["content"] == [ + {"type": "text", "text": "before ", "marks": [{"type": "strong"}]}, + {"type": "text", "text": "code", "marks": [{"type": "code"}]}, + {"type": "text", "text": " after", "marks": [{"type": "strong"}]}, + ] + + def test_inline_code_nested_in_emphasis_has_only_code_mark(self): + result = self.converter.convert("*before `code` after*") + + assert result[0]["content"] == [ + {"type": "text", "text": "before ", "marks": [{"type": "em"}]}, + {"type": "text", "text": "code", "marks": [{"type": "code"}]}, + {"type": "text", "text": " after", "marks": [{"type": "em"}]}, + ] + + def test_inline_code_in_link_preserves_link_and_code_marks(self): + result = self.converter.convert("[`code`](https://example.com)") + + assert result[0]["content"][0]["marks"] == [ + {"type": "link", "attrs": {"href": "https://example.com"}}, + {"type": "code"}, + ] + + def test_inline_code_in_emphasized_link_preserves_link_and_code_marks(self): + result = self.converter.convert("*[`code`](https://example.com)*") + + assert result[0]["content"][0]["marks"] == [ + {"type": "link", "attrs": {"href": "https://example.com"}}, + {"type": "code"}, + ] + + def test_standalone_inline_code_has_code_mark(self): + result = self.converter.convert("`code`") + + assert result[0]["content"][0]["marks"] == [{"type": "code"}] + + class TestJiraIntegration: @pytest.fixture(autouse=True) @patch.object(Jira, "get_auth", return_value=None) diff --git a/tests/lib/outputs/ocsf/ocsf_test.py b/tests/lib/outputs/ocsf/ocsf_test.py index cd1ba289ea..359e60957c 100644 --- a/tests/lib/outputs/ocsf/ocsf_test.py +++ b/tests/lib/outputs/ocsf/ocsf_test.py @@ -1,4 +1,5 @@ import json +import re from datetime import datetime, timezone from io import StringIO from typing import Optional @@ -28,11 +29,6 @@ from py_ocsf_models.objects.resource_details import ResourceDetails from pydantic.v1 import BaseModel as V1BaseModel from prowler.config.config import prowler_version -from prowler.lib.check.compliance_models import ( - Compliance, - Mitre_Requirement, - Mitre_Requirement_Attribute_AWS, -) from prowler.lib.outputs.ocsf.ocsf import OCSF from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.aws.utils import AWS_REGION_EU_WEST_1 @@ -148,89 +144,150 @@ class TestOCSF: 1619600000, tz=timezone.utc ) - def test_transform_mitre_attacks_populated(self): + def test_transform_mitre_attacks_from_multiple_finding_compliance_ids(self): finding = generate_finding_output( provider="aws", - compliance={"MITRE-ATTACK": ["T4242"]}, + compliance={"MITRE-ATTACK": ["T1078", "T1098"]}, check_id="iam_user_mfa_enabled_console_access", check_title="IAM users with console access have MFA enabled", service_name="iam", ) - finding.metadata.Compliance = [ - Compliance( - Framework="MITRE-ATTACK", - Name="MITRE ATT&CK compliance framework", - Provider="AWS", - Version="", - Description="MITRE ATT&CK test framework", - Requirements=[ - Mitre_Requirement( - Name="Synthetic Valid Accounts", - Id="T4242", - Tactics=["Persistence", "Privilege Escalation"], - SubTechniques=[], - Description="Synthetic MITRE technique for OCSF tests.", - Platforms=["IaaS"], - TechniqueURL="https://attack.mitre.org/techniques/T4242/", - Attributes=[ - Mitre_Requirement_Attribute_AWS( - AWSService="AWS IAM", - Category="Protect", - Value="Significant", - Comment="Test mapping", - ) - ], - Checks=["iam_user_mfa_enabled_console_access"], - ) - ], - ) - ] - ocsf = OCSF([finding]) + technique_map = { + "T1078": { + "Name": "Valid Accounts", + "TechniqueURL": "https://attack.mitre.org/techniques/T1078/", + "Tactics": [ + "Defense Evasion", + "Persistence", + "Privilege Escalation", + "Initial Access", + ], + }, + "T1098": { + "Name": "Account Manipulation", + "TechniqueURL": "https://attack.mitre.org/techniques/T1098/", + "Tactics": ["Persistence"], + }, + } + + with patch( + "prowler.lib.outputs.ocsf.ocsf._load_mitre_technique_map", + return_value=technique_map, + ): + ocsf = OCSF([finding]) output_data = ocsf.data[0] assert output_data.finding_info.attacks is not None - assert len(output_data.finding_info.attacks) == 2 + assert len(output_data.finding_info.attacks) == 5 attack = output_data.finding_info.attacks[0] assert isinstance(attack, MITREAttack) - assert attack.technique.uid == "T4242" - assert attack.technique.name == "Synthetic Valid Accounts" - assert attack.technique.src_url == "https://attack.mitre.org/techniques/T4242/" + assert attack.technique.uid == "T1078" + assert attack.technique.name == "Valid Accounts" + assert attack.technique.src_url == "https://attack.mitre.org/techniques/T1078/" assert attack.tactic is not None assert [attack.tactic.name for attack in output_data.finding_info.attacks] == [ + "Defense Evasion", "Persistence", "Privilege Escalation", + "Initial Access", + "Persistence", + ] + assert [ + attack.technique.uid for attack in output_data.finding_info.attacks + ] == [ + "T1078", + "T1078", + "T1078", + "T1078", + "T1098", ] - def test_transform_mitre_attacks_unknown_technique(self): + def test_transform_mitre_attacks_ignores_unknown_technique(self): finding = generate_finding_output( provider="aws", - compliance={"MITRE-ATTACK": ["T9999"]}, + compliance={"MITRE-ATTACK": ["T9999", "T1098"]}, ) - finding.metadata.Compliance = [ - Compliance( - Framework="MITRE-ATTACK", - Name="MITRE ATT&CK compliance framework", - Provider="AWS", - Version="", - Description="MITRE ATT&CK test framework", - Requirements=[], - ) - ] - ocsf = OCSF([finding]) - assert ocsf.data[0].finding_info.attacks is None + with patch( + "prowler.lib.outputs.ocsf.ocsf._load_mitre_technique_map", + return_value={ + "T1098": { + "Name": "Account Manipulation", + "Tactics": ["Persistence"], + } + }, + ): + ocsf = OCSF([finding]) + attacks = ocsf.data[0].finding_info.attacks - def test_transform_mitre_attacks_without_mitre_metadata(self): + assert attacks is not None + assert len(attacks) == 1 + assert attacks[0].technique.uid == "T1098" + + def test_transform_mitre_attacks_provider_without_catalog(self): finding = generate_finding_output( provider="kubernetes", compliance={"MITRE-ATTACK": ["T1078"]}, check_type=[], ) - ocsf = OCSF([finding]) + with patch( + "prowler.lib.outputs.ocsf.ocsf._load_mitre_technique_map", + return_value={}, + ): + ocsf = OCSF([finding]) assert ocsf.data[0].finding_info.attacks is None + def test_load_mitre_technique_map_logs_failure(self): + from prowler.lib.outputs.ocsf.ocsf import _load_mitre_technique_map + + _load_mitre_technique_map.cache_clear() + try: + with ( + patch("builtins.open", side_effect=OSError("catalog unavailable")), + patch("prowler.lib.outputs.ocsf.ocsf.logger.error") as mock_error, + ): + assert _load_mitre_technique_map("aws") == {} + + message = mock_error.call_args.args[0] + assert re.fullmatch(r"OSError\[\d+\]: catalog unavailable", message) + finally: + _load_mitre_technique_map.cache_clear() + + def test_transform_mitre_attacks_skips_technique_without_name(self): + findings = [ + generate_finding_output( + provider="aws", + compliance={"MITRE-ATTACK": ["T1078", "T1098"]}, + ), + generate_finding_output(provider="aws"), + ] + technique_map = { + "T1078": {"Tactics": ["Initial Access"]}, + "T1098": { + "Name": "Account Manipulation", + "Tactics": ["Persistence"], + }, + } + + with ( + patch( + "prowler.lib.outputs.ocsf.ocsf._load_mitre_technique_map", + return_value=technique_map, + ), + patch("prowler.lib.outputs.ocsf.ocsf.logger.warning") as mock_warning, + ): + ocsf = OCSF(findings) + + assert len(ocsf.data) == 2 + attacks = ocsf.data[0].finding_info.attacks + assert attacks is not None + assert [attack.technique.uid for attack in attacks] == ["T1098"] + mock_warning.assert_called_once_with( + "Skipping MITRE ATT&CK technique T1078 for provider aws: missing Name" + ) + def test_scan_id_is_unique_per_provider_and_account(self): findings = [ generate_finding_output(provider="aws", account_uid="111111111111"), diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py index d97b80336b..99cda27ae2 100644 --- a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py @@ -1,5 +1,7 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.lib.service.service import GCPService from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( CloudFunction, ) @@ -148,6 +150,90 @@ class TestCloudFunctionService: assert fn.vpc_connector is None assert fn.publicly_accessible is False + def test_get_functions_iam_policy_gen2_uses_distinct_per_request_http(self): + """Regression: the gen2 IAM lookup must pass a per-request HTTP client. + + _get_function_iam_policy runs once per function across a thread pool + (GCPService.__threading_call__), and httplib2 is not thread-safe. The + gen1 branch isolates each thread with its own AuthorizedHttp via + __get_AuthorizedHttp_client__; the gen2 branch must do the same. Sharing + the single self._run_client transport across threads corrupts the + process heap and aborts the scan (SIGABRT/SIGSEGV). + """ + second_function_name = "second-function" + second_function_id = f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/{second_function_name}" + second_run_service = f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/services/{second_function_name}" + first_http = object() + second_http = object() + + first_request = MagicMock() + first_request.execute.return_value = {"bindings": []} + second_request = MagicMock() + second_request.execute.return_value = {"bindings": []} + run_client = MagicMock() + get_iam_policy = run_client.projects().locations().services().getIamPolicy + get_iam_policy.side_effect = [first_request, second_request] + + def run_sequentially(self, callback, iterator): + for value in iterator: + callback(value) + + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + }, + { + "name": second_function_id, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": second_run_service}, + }, + ] + ) + + with ( + 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=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + patch.object( + GCPService, + "__get_AuthorizedHttp_client__", + side_effect=[first_http, second_http], + ), + patch.object( + GCPService, + "__threading_call__", + new=run_sequentially, + ), + ): + CloudFunction(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + get_iam_policy.assert_has_calls( + [call(resource=_RUN_SERVICE), call(resource=second_run_service)] + ) + first_request.execute.assert_called_once_with( + http=first_http, + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + second_request.execute.assert_called_once_with( + http=second_http, + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + def test_get_functions_iam_policy_gen2_all_users(self): """Gen2 functions: allUsers binding lives on the Cloud Run service.""" diff --git a/ui/Dockerfile b/ui/Dockerfile index 41484a3fbe..b3677bfbc3 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -87,7 +87,7 @@ ENV HOSTNAME="0.0.0.0" # NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work: # UI_SENTRY_ENABLED + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT) # UI_GOOGLE_TAG_MANAGER_ENABLED + UI_GOOGLE_TAG_MANAGER_ID -# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet) +# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (feedback survey) # - reserved: REO_DEV_CLIENT_ID (no consumer yet) # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output diff --git a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.test.tsx b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.test.tsx new file mode 100644 index 0000000000..a89c59eec9 --- /dev/null +++ b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ThreatScoreSSR } from "./threat-score.ssr"; + +vi.mock("@/actions/overview", () => ({ + getThreatScore: vi.fn(async () => ({ + data: [ + { + attributes: { + overall_score: "72", + score_delta: "2", + section_scores: {}, + critical_requirements: [], + }, + }, + ], + })), +})); + +vi.mock("@/components/lighthouse/context-contributor", () => ({ + LighthouseContextContributor: ({ item }: { item: unknown }) => ( + {JSON.stringify(item)} + ), +})); + +vi.mock("./_components/threat-score", () => ({ + ThreatScore: ({ score }: { score?: number }) =>
Score {score}
, +})); + +describe("ThreatScoreSSR", () => { + it("publishes the loaded overview score as Lighthouse context", async () => { + render(await ThreatScoreSSR({ searchParams: {} })); + + expect(screen.getByTestId("overview-context")).toHaveTextContent( + '"score":72', + ); + expect(screen.getByText("Score 72")).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx index 244c50527e..98af813406 100644 --- a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx +++ b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx @@ -1,4 +1,6 @@ import { getThreatScore } from "@/actions/overview"; +import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor"; +import { buildComplianceContext } from "@/lib/lighthouse/context/contributions"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; @@ -25,11 +27,23 @@ export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => { : null; return ( - + <> + + + ); }; diff --git a/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx b/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx index e59ab4e915..0db318952a 100644 --- a/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx +++ b/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx @@ -3,6 +3,8 @@ import Image, { type StaticImageData } from "next/image"; import { useState } from "react"; +import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url"; + import { SortToggleButton } from "./sort-toggle-button"; import { WatchlistCard } from "./watchlist-card"; @@ -47,7 +49,7 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => { title="Compliance Watchlist" items={sortedItems} ctaLabel="Explore Compliance for Each Scan" - ctaHref="/compliance" + ctaHref={buildPerScanComplianceHref()} headerAction={ { + it("renders a Prowler Hub link for a built-in query", () => { + // Given + render(); + + // When + const link = screen.getByRole("link", { name: /view on prowler hub/i }); + + // Then + expect(link).toHaveAttribute( + "href", + "https://hub.prowler.com/attack-paths/aws-sts-privesc-assume-role", + ); + }); + + it("does not render a Prowler Hub link for the custom query", () => { + // Given + const query: AttackPathQuery = { + ...customQuery, + id: ATTACK_PATH_QUERY_IDS.CUSTOM, + }; + + // When + render(); + + // Then + expect( + screen.queryByRole("link", { name: /view on prowler hub/i }), + ).not.toBeInTheDocument(); + }); + it("renders the documentation link inside an info alert", () => { // Given render(); @@ -39,9 +86,9 @@ describe("QueryDescription", () => { expect(link).toHaveAttribute("href", "https://example.com/docs"); }); - it("does not render unsafe documentation or attribution URLs as clickable links", () => { + it("does not render an unsafe documentation URL as a clickable link", () => { // Given - const queryWithUnsafeLinks: AttackPathQuery = { + const queryWithUnsafeLink: AttackPathQuery = { ...customQuery, attributes: { ...customQuery.attributes, @@ -49,15 +96,11 @@ describe("QueryDescription", () => { text: "Learn how to write custom openCypher queries", link: "javascript:alert('xss')", }, - attribution: { - text: "Unsafe source", - link: "javascript:alert('xss')", - }, }, }; // When - render(); + render(); // Then expect( @@ -65,12 +108,8 @@ describe("QueryDescription", () => { name: /learn how to write custom opencypher queries/i, }), ).not.toBeInTheDocument(); - expect( - screen.queryByRole("link", { name: /unsafe source/i }), - ).not.toBeInTheDocument(); expect( screen.getByText(/learn how to write custom opencypher queries/i), ).toBeInTheDocument(); - expect(screen.getByText(/unsafe source/i)).toBeInTheDocument(); }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx index d1cb6e85fe..5335e4ab9d 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx @@ -1,7 +1,11 @@ import { Info } from "lucide-react"; import { Alert, AlertDescription } from "@/components/shadcn"; -import type { AttackPathQuery } from "@/types/attack-paths"; +import { getAttackPathHubUrl } from "@/lib/external-urls"; +import { + ATTACK_PATH_QUERY_IDS, + type AttackPathQuery, +} from "@/types/attack-paths"; interface QueryDescriptionProps { query: AttackPathQuery; @@ -18,7 +22,12 @@ const isSafeUrl = (url: string): boolean => { export const QueryDescription = ({ query }: QueryDescriptionProps) => { const documentationLink = query.attributes.documentation_link; - const attribution = query.attributes.attribution; + // Every built-in query has a Prowler Hub page keyed by its id. The synthetic + // custom query has no catalog entry, so it gets no hub link. + const hubUrl = + query.id === ATTACK_PATH_QUERY_IDS.CUSTOM + ? null + : getAttackPathHubUrl(query.id); return ( @@ -26,6 +35,19 @@ export const QueryDescription = ({ query }: QueryDescriptionProps) => {

{query.attributes.description}

+ {hubUrl && ( +

+ + View on Prowler Hub + +

+ )} + {documentationLink && (

{isSafeUrl(documentationLink.link) ? ( @@ -42,28 +64,6 @@ export const QueryDescription = ({ query }: QueryDescriptionProps) => { )}

)} - - {attribution && ( -

- {isSafeUrl(attribution.link) ? ( - <> - Source:{" "} - - {attribution.text} - - - ) : ( - <> - Source: {attribution.text} - - )} -

- )}
); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts index 6484a33843..77c2214f7b 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts @@ -4,6 +4,7 @@ import { create } from "zustand"; import type { AttackPathGraphData, + AttackPathQueryExecution, GraphNode, GraphState, } from "@/types/attack-paths"; @@ -22,7 +23,10 @@ interface FilteredViewState { } interface GraphStore extends GraphState, FilteredViewState { - setGraphData: (data: AttackPathGraphData) => void; + setGraphData: ( + data: AttackPathGraphData, + execution: AttackPathQueryExecution | null, + ) => void; setSelectedNodeId: (nodeId: string | null) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; @@ -38,6 +42,7 @@ interface GraphStore extends GraphState, FilteredViewState { const initialState: GraphState & FilteredViewState = { data: null, + execution: null, selectedNodeId: null, loading: false, error: null, @@ -49,9 +54,10 @@ const initialState: GraphState & FilteredViewState = { export const useGraphStore = create((set) => ({ ...initialState, - setGraphData: (data) => + setGraphData: (data, execution) => set({ data, + execution, fullData: null, error: null, isFilteredView: false, @@ -88,8 +94,11 @@ export const useGraphState = () => { const store = useGraphStore(); // Zustand store methods are stable, no need to memoize - const updateGraphData = (data: AttackPathGraphData) => { - store.setGraphData(data); + const updateGraphData = ( + data: AttackPathGraphData, + execution: AttackPathQueryExecution, + ) => { + store.setGraphData(data, execution); }; const selectNode = (nodeId: string | null) => { @@ -120,7 +129,7 @@ export const useGraphState = () => { }; const clearGraph = () => { - store.setGraphData({ nodes: [], edges: [] }); + store.setGraphData({ nodes: [], edges: [] }, null); store.setSelectedNodeId(null); store.setFilteredView(false, null, null, null); }; @@ -161,6 +170,7 @@ export const useGraphState = () => { return { data: store.data, + execution: store.execution, fullData: store.fullData, selectedNodeId: store.selectedNodeId, selectedNode: getSelectedNode(), diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx index f882d46efd..3e842e4b10 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -15,6 +15,8 @@ import { beforeEach, describe, expect, test as base, vi } from "vitest"; import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths"; import { worker } from "@/__tests__/msw/worker"; import { render } from "@/__tests__/render-browser"; +import { useLighthouseContextStore } from "@/store/lighthouse-context/store"; +import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils"; const { getFindingByIdMock } = vi.hoisted(() => ({ getFindingByIdMock: vi.fn(), @@ -50,6 +52,7 @@ interface Fixtures { // one (selection, filtered view, expanded resources, etc.). beforeEach(() => { useGraphStore.getState().reset(); + resetLighthouseContextStore(); getFindingByIdMock.mockClear(); }); @@ -101,17 +104,6 @@ describe("waiting states", () => { }); describe("running a query", () => { - test("the query builder surface uses the shared card primitive", async ({ - mountWith, - }) => { - const graph = await mountWith(); - - const card = await graph.waitFor(() => graph.queryBuilderCard, 10000); - - expect(card).toHaveAttribute("data-slot", "card"); - expect(card).toHaveClass("rounded-xl"); - }); - test("a parameterized query shows its required inputs after selection", async ({ mountWith, }) => { @@ -126,16 +118,75 @@ describe("running a query", () => { expect(graph.getInputByName("tag_value")).toBeTruthy(); }); - test("the graph renders with a background, a minimap, and a viewport", async ({ + test("changing the form keeps Lighthouse bound to the query that produced the graph", async ({ mountWith, }) => { + // Given + const fixture = fixtures.typical(); + const graph = await mountWith(fixture); + await graph.executeQuery(); + + // When + await graph.selectQuery("aws-open-security-groups"); + + // Then + expect( + useLighthouseContextStore.getState().contributions["attack-path-current"], + ).toMatchObject({ + queryId: fixture.queryId, + queryKind: "predefined", + canReplayQuery: true, + label: "Public S3 buckets", + nodeCount: fixture.queryResult?.nodes.length, + edgeCount: fixture.queryResult?.relationships?.length, + }); + }); + + test("editing parameters keeps Lighthouse bound to the executed values", async ({ + mountWith, + }) => { + // Given + const graph = await mountWith(fixtures.parameterizedQuery()); + await graph.selectQuery(); + await graph.fillInput("tag_key", "DataClassification"); + await graph.fillInput("tag_value", "Sensitive"); + await graph.executeQuery({ selectFirst: false }); + + // When + await graph.fillInput("tag_value", "Confidential"); + + // Then + expect( + useLighthouseContextStore.getState().contributions["attack-path-current"], + ).toMatchObject({ + parameters: { + tag_key: "DataClassification", + tag_value: "Sensitive", + }, + }); + }); + + test("loading another execution removes stale graph context", async ({ + mountWith, + }) => { + // Given const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForGraphStable(3); - expect(graph.background).toBeTruthy(); - expect(graph.minimap).toBeTruthy(); - expect(graph.viewport).toBeTruthy(); + // When + useGraphStore.getState().setLoading(true); + + // Then + await vi.waitFor(() => + expect( + useLighthouseContextStore.getState().contributions[ + "attack-path-current" + ], + ).toMatchObject({ + id: "current-scan", + queryId: undefined, + }), + ); }); test("nodes are laid out at distinct positions", async ({ mountWith }) => { @@ -147,19 +198,6 @@ describe("running a query", () => { expect(positions.some((p) => p.x !== 0 || p.y !== 0)).toBe(true); }); - test("the toolbar exposes zoom, fit, and export controls", async ({ - mountWith, - }) => { - const graph = await mountWith(); - await graph.executeQuery(); - await graph.waitForGraphStable(1); - - expect(graph.toolbar.zoomInButton).toBeTruthy(); - expect(graph.toolbar.zoomOutButton).toBeTruthy(); - expect(graph.toolbar.fitButton).toBeTruthy(); - expect(graph.toolbar.exportButton).toBeTruthy(); - }); - test("finding, resource, and internet nodes all render", async ({ mountWith, }) => { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts index ebae771f3d..1bcfcec6cb 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts @@ -233,6 +233,12 @@ export class AttackPathPageHarness { ); } + async fillInput(name: string, value: string): Promise { + const input = this.getInputByName(name); + if (!input) throw new Error(`fillInput: input "${name}" not found`); + await this.user.fill(input, value); + } + /** * Inline `transform` of the React Flow viewport element. This is the * pan/zoom matrix React Flow rewrites on every fit/zoom/pan, so comparing diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index d8d04e9def..4875676417 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -13,6 +13,7 @@ import { } from "@/actions/attack-paths"; import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; import { FindingDetailDrawer } from "@/components/findings/table"; +import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor"; import { PageReady } from "@/components/onboarding"; import { useFindingDetails } from "@/components/resources/table/use-finding-details"; import { AutoRefresh } from "@/components/scans"; @@ -27,6 +28,7 @@ import { } from "@/components/shadcn/dialog"; import { StatusAlert } from "@/components/shared/status-alert"; import { useMountEffect } from "@/hooks/use-mount-effect"; +import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions"; import { isCloud } from "@/lib/shared/env"; import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour"; import { @@ -41,7 +43,11 @@ import type { AttackPathQueryError, GraphNode, } from "@/types/attack-paths"; -import { ATTACK_PATH_QUERY_IDS, SCAN_STATES } from "@/types/attack-paths"; +import { + ATTACK_PATH_QUERY_IDS, + ATTACK_PATH_QUERY_KIND, + SCAN_STATES, +} from "@/types/attack-paths"; import { AttackPathGraph, @@ -258,12 +264,14 @@ export default function AttackPathsPage() { graphState.setError(null); try { - const parameters = queryBuilder.getQueryParameters(); - const isCustomQuery = - queryBuilder.selectedQuery === ATTACK_PATH_QUERY_IDS.CUSTOM; + const queryId = queryBuilder.selectedQuery; + const queryLabel = + queryBuilder.selectedQueryData?.attributes.name ?? queryId; + const parameters = { ...queryBuilder.getQueryParameters() }; + const isCustomQuery = queryId === ATTACK_PATH_QUERY_IDS.CUSTOM; const result = isCustomQuery ? await executeCustomQuery(scanId, String(parameters?.query ?? "")) - : await executeQuery(scanId, queryBuilder.selectedQuery, parameters); + : await executeQuery(scanId, queryId, parameters); if (result && "error" in result) { const apiError = result as AttackPathQueryError; @@ -289,7 +297,14 @@ export default function AttackPathsPage() { } } else if (result?.data?.attributes) { const graphData = adaptQueryResultToGraphData(result.data.attributes); - graphState.updateGraphData(graphData); + graphState.updateGraphData(graphData, { + queryId, + queryLabel, + queryKind: isCustomQuery + ? ATTACK_PATH_QUERY_KIND.CUSTOM + : ATTACK_PATH_QUERY_KIND.PREDEFINED, + parameters, + }); toast({ title: "Success", description: "Query executed successfully", @@ -396,6 +411,29 @@ export default function AttackPathsPage() { } }; + const lighthouseSelectedNode = + graphState.selectedNode ?? graphState.filteredNode; + const lighthouseGraphData = graphState.fullData ?? graphState.data; + const lighthouseExecution = graphState.loading ? null : graphState.execution; + const lighthouseContext = scanId + ? buildAttackPathContext({ + pathname, + scanId, + queryId: lighthouseExecution?.queryId, + queryLabel: lighthouseExecution?.queryLabel, + queryKind: lighthouseExecution?.queryKind, + parameters: lighthouseExecution?.parameters, + graphData: lighthouseExecution ? lighthouseGraphData : null, + selectedNode: + lighthouseExecution && lighthouseSelectedNode + ? { + id: lighthouseSelectedNode.id, + type: lighthouseSelectedNode.labels[0], + } + : null, + }) + : null; + return (
} + {lighthouseContext && ( + + )} +

Select a scan, build a query, and visualize Attack Paths in your diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index d1d734ea66..d5ad1b1c5b 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -26,6 +26,7 @@ import { TopFailedSectionsCardSkeleton, } from "@/components/compliance"; import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor"; import { Button } from "@/components/shadcn/button/button"; import { Card } from "@/components/shadcn/card/card"; import { ContentLayout } from "@/components/shadcn/content-layout"; @@ -34,6 +35,8 @@ import { getReportTypeForCompliance, pickLatestCisPerProvider, } from "@/lib/compliance/compliance-report-types"; +import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants"; +import { buildComplianceContext } from "@/lib/lighthouse/context/contributions"; import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; import type { SearchParamsProps } from "@/types"; @@ -42,8 +45,10 @@ import { Framework, RequirementsTotals, } from "@/types/compliance"; +import { isKnownProviderType } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; +import { CrossAccountDetail } from "../_components/cross-account-detail"; import { CrossProviderDetail } from "../_components/cross-provider-detail"; import { resolveCrossProviderFramework } from "../_lib/cross-provider-frameworks"; import { buildSearchParamsKey } from "../_lib/search-params-key"; @@ -75,7 +80,7 @@ export default async function ComplianceDetail({ // Cross-provider mode replaces the per-scan pipeline with the universal // roll-up view. Prowler Cloud-only: the OSS API has no such endpoint, so // the route is blocked in OSS the same way the compliance tab is. - if (mode === "cross-provider") { + if (mode === LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_PROVIDER) { if (!isCloud()) { redirect("/compliance"); } @@ -113,6 +118,51 @@ export default async function ComplianceDetail({ ); } + // Cross-account mode: one regular framework aggregated across every + // account of one provider type. Cloud-only, like cross-provider. + if (mode === LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_ACCOUNT) { + if (!isCloud()) { + redirect("/compliance"); + } + + const providerType = getSingleSearchParam( + resolvedSearchParams.providerType, + ); + if (!providerType || !isKnownProviderType(providerType)) { + notFound(); + } + + const crossAccountTitle = compliancetitle.split("-").join(" "); + return ( + + +

+ + +
+ +
+ } + > + + + + ); + } + const regionFilter = getSingleSearchParam( resolvedSearchParams["filter[region__in]"], ); @@ -295,7 +345,10 @@ export default async function ComplianceDetail({ > + {/* Charts section */} {/* Mobile: each card on own row | Tablet: ThreatScore full row, others share row | Desktop: all 3 in one row */}
({ + captureExceptionMock: vi.fn(), + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.", + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), +})); + +vi.mock("@/lib/auth-headers", () => ({ + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +vi.mock("@sentry/nextjs", () => ({ + captureException: captureExceptionMock, +})); + +import { + generateCrossAccountPdf, + getCrossAccountComplianceOverview, + getCrossAccountPdfBinary, + getLatestCrossAccountPdf, +} from "./cross-account"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/vnd.api+json" }, + }); + +const lastFetchUrl = () => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + return new URL(String(call[0])); +}; + +const fetchCallAt = (index: number) => { + const call = fetchMock.mock.calls[index]; + if (!call) throw new Error(`fetch call ${index} was not found`); + return { + init: call[1] as RequestInit, + url: new URL(String(call[0])), + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue(jsonResponse({ data: null })); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer test-token" }); + handleApiResponseMock.mockResolvedValue({ data: null }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("cross-account compliance actions", () => { + it("loads the overview with its identity and account filters", async () => { + const payload = { data: { id: "cis_2.0_aws" } }; + handleApiResponseMock.mockResolvedValue(payload); + + const result = await getCrossAccountComplianceOverview({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { + scanIds: ["scan-1", "scan-2"], + providerIds: "provider-1,provider-2", + providerGroups: "group-1", + }, + }); + + expect(result).toEqual({ status: "success", response: payload }); + const url = lastFetchUrl(); + expect(url.pathname).toBe("/api/v1/cross-account-compliance-overviews"); + expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws"); + expect(url.searchParams.get("filter[provider_type]")).toBe("aws"); + expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1,scan-2"); + expect(url.searchParams.get("filter[provider_id__in]")).toBe( + "provider-1,provider-2", + ); + expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1"); + }); + + it("routes PDF operations through the cross-account endpoints", async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202), + ) + .mockResolvedValueOnce( + new Response(Buffer.from("pdf-bytes"), { + headers: { + "Content-Disposition": 'attachment; filename="report.pdf"', + "Content-Type": "application/pdf", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + data: { + id: "task-2", + attributes: { result: { filename: "latest.pdf" } }, + }, + }), + ); + + await generateCrossAccountPdf({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { scanIds: ["scan-1"] }, + reportName: "report.pdf", + }); + await getCrossAccountPdfBinary("task-1"); + await getLatestCrossAccountPdf({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { providerIds: "provider-1" }, + }); + + const generation = fetchCallAt(0); + expect(generation.init.method).toBe("POST"); + expect(generation.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf", + ); + expect(generation.url.searchParams.get("filter[compliance_id]")).toBe( + "cis_2.0_aws", + ); + expect(generation.url.searchParams.get("filter[provider_type]")).toBe( + "aws", + ); + expect(generation.url.searchParams.get("filter[scan__in]")).toBe("scan-1"); + expect(generation.url.searchParams.get("report_name")).toBe("report.pdf"); + + const binary = fetchCallAt(1); + expect(binary.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf/task-1", + ); + + const latest = fetchCallAt(2); + expect(latest.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf/latest", + ); + expect(latest.url.searchParams.get("filter[provider_id__in]")).toBe( + "provider-1", + ); + + expect([generation, binary, latest].every(({ init }) => init.signal)).toBe( + true, + ); + }); + + it("aborts a stalled request and reports the network failure", async () => { + vi.useFakeTimers(); + let requestSignal: AbortSignal | undefined; + fetchMock.mockImplementation( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + requestSignal = init?.signal ?? undefined; + requestSignal?.addEventListener("abort", () => { + reject(requestSignal?.reason ?? new Error("aborted")); + }); + }), + ); + + const request = getCrossAccountComplianceOverview({ + complianceId: "cis_2.0_aws", + providerType: "aws", + }); + await vi.advanceTimersByTimeAsync(30_000); + + await expect(request).resolves.toEqual({ + status: "load-error", + message: + "Could not load cross-provider compliance data. Try again later.", + }); + expect(requestSignal?.aborted).toBe(true); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/app/(prowler)/compliance/_actions/cross-account.ts b/ui/app/(prowler)/compliance/_actions/cross-account.ts new file mode 100644 index 0000000000..06568bba70 --- /dev/null +++ b/ui/app/(prowler)/compliance/_actions/cross-account.ts @@ -0,0 +1,126 @@ +"use server"; + +import type { ScanBinaryResult } from "@/actions/scans/scans"; +import { apiBaseUrl } from "@/lib"; + +import { + generateAggregatedCompliancePdf, + getAggregatedComplianceOverview, + getAggregatedCompliancePdfBinary, + getLatestAggregatedCompliancePdf, +} from "../_lib/aggregated-compliance-actions"; +import type { + CrossAccountApiFilters, + CrossAccountOverviewResponse, + CrossAccountOverviewResult, + LatestCrossProviderPdf, +} from "../_types"; + +const CROSS_ACCOUNT_API_PATH = "/cross-account-compliance-overviews"; + +const applyCrossAccountParams = ( + url: URL, + complianceId: string, + providerType: string, + filters?: CrossAccountApiFilters, +) => { + url.searchParams.set("filter[compliance_id]", complianceId); + url.searchParams.set("filter[provider_type]", providerType); + + if (filters?.scanIds?.length) { + url.searchParams.set("filter[scan__in]", filters.scanIds.join(",")); + } + + const params = { + "filter[provider_id__in]": filters?.providerIds, + "filter[provider_groups__in]": filters?.providerGroups, + }; + for (const [key, value] of Object.entries(params)) { + if (value?.trim()) url.searchParams.set(key, value); + } +}; + +const buildCrossAccountUrl = ( + suffix: string, + complianceId: string, + providerType: string, + filters?: CrossAccountApiFilters, +) => { + const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}${suffix}`); + applyCrossAccountParams(url, complianceId, providerType, filters); + return url; +}; + +export const getCrossAccountComplianceOverview = async ({ + complianceId, + providerType, + filters, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; +}): Promise => { + const url = buildCrossAccountUrl("", complianceId, providerType, filters); + return getAggregatedComplianceOverview( + url, + `GET ${CROSS_ACCOUNT_API_PATH}`, + ); +}; + +export const generateCrossAccountPdf = async ({ + complianceId, + providerType, + filters, + reportName, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; + reportName?: string; +}): Promise<{ taskId: string } | { error: string }> => { + const url = buildCrossAccountUrl("/pdf", complianceId, providerType, filters); + if (reportName) url.searchParams.set("report_name", reportName); + return generateAggregatedCompliancePdf( + url, + `POST ${CROSS_ACCOUNT_API_PATH}/pdf`, + ); +}; + +export const getCrossAccountPdfBinary = async ( + taskId: string, +): Promise => { + const safeTaskId = taskId.trim(); + if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) { + return { error: "Invalid task identifier." }; + } + + const url = new URL( + `${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`, + ); + return getAggregatedCompliancePdfBinary({ + url, + operation: `GET ${CROSS_ACCOUNT_API_PATH}/pdf/{taskId}`, + defaultFilename: "cross-account-compliance.pdf", + }); +}; + +export const getLatestCrossAccountPdf = async ({ + complianceId, + providerType, + filters, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; +}): Promise => { + const url = buildCrossAccountUrl( + "/pdf/latest", + complianceId, + providerType, + filters, + ); + return getLatestAggregatedCompliancePdf( + url, + `GET ${CROSS_ACCOUNT_API_PATH}/pdf/latest`, + ); +}; diff --git a/ui/app/(prowler)/compliance/_actions/cross-provider.ts b/ui/app/(prowler)/compliance/_actions/cross-provider.ts index 90a81a9580..1cd9f77fac 100644 --- a/ui/app/(prowler)/compliance/_actions/cross-provider.ts +++ b/ui/app/(prowler)/compliance/_actions/cross-provider.ts @@ -1,119 +1,54 @@ "use server"; -import * as Sentry from "@sentry/nextjs"; - import type { ScanBinaryResult } from "@/actions/scans/scans"; -import { - apiBaseUrl, - GENERIC_SERVER_ERROR_MESSAGE, - getErrorMessage, -} from "@/lib"; -import { hasActionError } from "@/lib/action-errors"; -import { getAuthHeaders } from "@/lib/auth-headers"; -import { handleApiResponse } from "@/lib/server-actions-helper"; -import { SentryErrorSource, SentryErrorType } from "@/sentry"; +import { apiBaseUrl } from "@/lib"; +import { + generateAggregatedCompliancePdf, + getAggregatedComplianceOverview, + getAggregatedCompliancePdfBinary, + getLatestAggregatedCompliancePdf, +} from "../_lib/aggregated-compliance-actions"; import type { CrossProviderApiFilters, CrossProviderOverviewResponse, CrossProviderOverviewResult, LatestCrossProviderPdf, } from "../_types"; -import { - CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, - CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, -} from "../_types"; const CROSS_PROVIDER_API_PATH = "/cross-provider-compliance-overviews"; -/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */ -interface PdfEndpointErrorBody { - errors?: Array<{ detail?: string }>; - error?: string; - message?: string; -} +const applyCrossProviderParams = ( + url: URL, + complianceId: string, + filters?: CrossProviderApiFilters, +) => { + url.searchParams.set("filter[compliance_id]", complianceId); -/** - * Extracts a user-safe message from a failed PDF endpoint response and, for - * unexpected failures, reports it to Sentry. `operation` must be a STATIC - * route template (e.g. `GET .../pdf/{taskId}`) — never the - * resolved URL, which would carry the task id or a user-typed report name. - */ -const getPdfEndpointErrorMessage = async ( - response: Response, - fallbackMessage: string, - operation: string, -): Promise => { - const contentType = response.headers.get("content-type")?.toLowerCase() || ""; - const errorData: PdfEndpointErrorBody | null = contentType.includes( - "text/html", - ) - ? null - : await response.json().catch(() => null); - - // These endpoints bypass handleApiResponse (binary/task protocol), so - // server failures would otherwise go unmonitored. - if (response.status >= 500) { - Sentry.captureException( - new Error( - `Cross-provider PDF request failed (${response.status}) at ${operation}`, - ), - { - tags: { - api_error: true, - status_code: response.status.toString(), - error_type: SentryErrorType.SERVER_ERROR, - error_source: SentryErrorSource.SERVER_ACTION, - }, - level: "error", - contexts: { - api_response: { - status: response.status, - statusText: response.statusText, - operation, - }, - }, - }, - ); - return GENERIC_SERVER_ERROR_MESSAGE; - } - - return ( - errorData?.errors?.[0]?.detail || - errorData?.error || - errorData?.message || - fallbackMessage - ); -}; - -/** Appends the shared cross-provider filter params to a request URL. */ -const applyFilters = (url: URL, filters?: CrossProviderApiFilters) => { - if (!filters) return; - - if (filters.scanIds && filters.scanIds.length > 0) { + if (filters?.scanIds?.length) { url.searchParams.set("filter[scan__in]", filters.scanIds.join(",")); } - const paramMap = { - "filter[provider_type__in]": filters.providerTypes, - "filter[provider_id__in]": filters.providerIds, - "filter[provider_groups__in]": filters.providerGroups, + const params = { + "filter[provider_type__in]": filters?.providerTypes, + "filter[provider_id__in]": filters?.providerIds, + "filter[provider_groups__in]": filters?.providerGroups, }; - for (const [key, value] of Object.entries(paramMap)) { - if (value && value.trim().length > 0) { - url.searchParams.set(key, value); - } + for (const [key, value] of Object.entries(params)) { + if (value?.trim()) url.searchParams.set(key, value); } }; -/** - * Aggregate a universal compliance framework across one scan per compatible - * provider (Prowler Cloud only — the OSS API has no such endpoint). - * - * When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED - * scan per compatible provider. Non-2xx responses are returned as structured - * action errors so callers can reuse the app-wide 402/403 handlers. - */ +const buildCrossProviderUrl = ( + suffix: string, + complianceId: string, + filters?: CrossProviderApiFilters, +) => { + const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}${suffix}`); + applyCrossProviderParams(url, complianceId, filters); + return url; +}; + export const getCrossProviderComplianceOverview = async ({ complianceId, filters, @@ -121,45 +56,13 @@ export const getCrossProviderComplianceOverview = async ({ complianceId: string; filters?: CrossProviderApiFilters; }): Promise => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}`); - url.searchParams.set("filter[compliance_id]", complianceId); - applyFilters(url, filters); - - try { - const response = await fetch(url.toString(), { headers }); - const responseData = await handleApiResponse(response); - - if (hasActionError(responseData)) { - return { - status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR, - result: responseData, - }; - } - - return { - status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS, - response: responseData as CrossProviderOverviewResponse, - }; - } catch (error) { - console.error("Error fetching cross-provider compliance overview:", error); - return { - status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, - message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, - }; - } + const url = buildCrossProviderUrl("", complianceId, filters); + return getAggregatedComplianceOverview( + url, + `GET ${CROSS_PROVIDER_API_PATH}`, + ); }; -/** - * Trigger ad-hoc generation of the combined cross-provider compliance PDF. - * - * The PDF is built asynchronously by a backend task: this returns the task id - * so the caller can poll it and then download via - * {@link getCrossProviderPdfBinary}. Pass the exact `scanIds` currently on - * screen (`attributes.scan_ids`) so the report matches the displayed data - * instead of re-resolving "latest scan per provider", which could race a scan - * completing in between. - */ export const generateCrossProviderPdf = async ({ complianceId, filters, @@ -167,109 +70,34 @@ export const generateCrossProviderPdf = async ({ }: { complianceId: string; filters?: CrossProviderApiFilters; - /** Optional download filename; sanitized server-side. */ reportName?: string; }): Promise<{ taskId: string } | { error: string }> => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf`); - url.searchParams.set("filter[compliance_id]", complianceId); - applyFilters(url, filters); + const url = buildCrossProviderUrl("/pdf", complianceId, filters); if (reportName) url.searchParams.set("report_name", reportName); - - try { - const response = await fetch(url.toString(), { method: "POST", headers }); - - if (!response.ok) { - throw new Error( - await getPdfEndpointErrorMessage( - response, - "Unable to start PDF generation. Contact support if the issue continues.", - `POST ${CROSS_PROVIDER_API_PATH}/pdf`, - ), - ); - } - - const json = await response.json(); - const taskId = json?.data?.id; - if (!taskId) { - throw new Error("Unexpected response starting PDF generation."); - } - - return { taskId }; - } catch (error) { - return { error: getErrorMessage(error) }; - } + return generateAggregatedCompliancePdf( + url, + `POST ${CROSS_PROVIDER_API_PATH}/pdf`, + ); }; -/** - * Fetch the finished cross-provider PDF for a task started by - * {@link generateCrossProviderPdf}. Speaks the same 202-pending / - * 2xx-binary / error-JSON protocol as the per-scan report endpoints, so it - * returns the shared {@link ScanBinaryResult} shape and callers can reuse the - * existing download plumbing unchanged. - */ export const getCrossProviderPdfBinary = async ( taskId: string, ): Promise => { - // The task id reaches the URL path: constrain it to the task-id charset - // (UUIDs) so a crafted value cannot smuggle `/`, `..` or a host. const safeTaskId = taskId.trim(); if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) { return { error: "Invalid task identifier." }; } - const headers = await getAuthHeaders({ contentType: false }); const url = new URL( `${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`, ); - - try { - const response = await fetch(url.toString(), { headers }); - - if (response.status === 202) { - const json = await response.json(); - return { - pending: true, - state: json?.data?.attributes?.state, - taskId: json?.data?.id, - }; - } - - if (!response.ok) { - throw new Error( - await getPdfEndpointErrorMessage( - response, - "Unable to retrieve the compliance PDF report. Contact support if the issue continues.", - `GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`, - ), - ); - } - - const contentDisposition = - response.headers.get("content-disposition") || ""; - const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); - const filename = filenameMatch?.[1] || "cross-provider-compliance.pdf"; - - const arrayBuffer = await response.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); - - return { success: true, data: base64, filename }; - } catch (error) { - return { error: getErrorMessage(error) }; - } + return getAggregatedCompliancePdfBinary({ + url, + operation: `GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`, + defaultFilename: "cross-provider-compliance.pdf", + }); }; -/** - * Check whether a cross-provider PDF already exists for the given filters so - * the UI can offer "Download" immediately instead of forcing a re-generate. - * - * 404 means "not generated yet" — a normal state, returned as `null` rather - * than an error. The backend only matches reports built from the exact scan - * set the filters resolve to, so a report goes stale (→ `null`) as soon as a - * contributing provider completes a new scan. Failures also degrade to - * `null`: this is an optional availability check and the caller's fallback - * (show "Generate") is always safe. - */ export const getLatestCrossProviderPdf = async ({ complianceId, filters, @@ -277,39 +105,9 @@ export const getLatestCrossProviderPdf = async ({ complianceId: string; filters?: CrossProviderApiFilters; }): Promise => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf/latest`); - url.searchParams.set("filter[compliance_id]", complianceId); - applyFilters(url, filters); - - try { - const response = await fetch(url.toString(), { headers }); - - if (response.status === 404) return null; - - if (!response.ok) { - throw new Error( - await getPdfEndpointErrorMessage( - response, - "Unable to check for an existing PDF report.", - `GET ${CROSS_PROVIDER_API_PATH}/pdf/latest`, - ), - ); - } - - const json = await response.json(); - const taskId = json?.data?.id; - if (!taskId) return null; - - return { - taskId, - filename: json?.data?.attributes?.result?.filename, - completedAt: json?.data?.attributes?.completed_at, - }; - } catch (error) { - // Degraded on purpose, but logged: without this a systematically failing - // endpoint would be indistinguishable from "never generated". - console.error("Error checking for an existing cross-provider PDF:", error); - return null; - } + const url = buildCrossProviderUrl("/pdf/latest", complianceId, filters); + return getLatestAggregatedCompliancePdf( + url, + `GET ${CROSS_PROVIDER_API_PATH}/pdf/latest`, + ); }; diff --git a/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.test.tsx b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.test.tsx new file mode 100644 index 0000000000..08643b3937 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.test.tsx @@ -0,0 +1,141 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AggregatedComplianceDetail } from "./aggregated-compliance-detail"; + +vi.mock("@/components/compliance", () => ({ + ClientAccordionWrapper: () =>
, + RequirementsStatusCard: () =>
, + TopFailedSectionsCard: () =>
, +})); + +describe("AggregatedComplianceDetail", () => { + it("stacks actions on mobile and keeps the link after the title on desktop", () => { + // Given / When + const { container } = render( + CSA Cloud Controls Matrix} + description={

5 of 5 compatible providers scanned

} + headerLink={View on Prowler Hub} + reportAction={} + filters={
Filters
} + totals={{ pass: 1, fail: 2, manual: 3 }} + coverage={
Coverage
} + topFailed={{ sections: [], dataType: "sections" }} + accordionItems={[]} + initialExpandedKeys={[]} + />, + ); + + // Then + const logo = screen.getByAltText("CSA-CCM logo"); + const header = container.querySelector( + '[data-slot="aggregated-compliance-header"]', + ); + const heading = container.querySelector( + '[data-slot="aggregated-compliance-heading"]', + ); + const title = container.querySelector( + '[data-slot="aggregated-compliance-title"]', + ); + const description = container.querySelector( + '[data-slot="aggregated-compliance-description"]', + ); + const headerLink = container.querySelector( + '[data-slot="aggregated-compliance-header-link"]', + ); + const reportAction = container.querySelector( + '[data-slot="aggregated-compliance-report-action"]', + ); + if ( + !header || + !heading || + !title || + !description || + !headerLink || + !reportAction + ) { + throw new Error("Expected every aggregated compliance header region"); + } + + expect(header).toHaveClass("sm:grid-cols-[auto_minmax(0,1fr)_auto]"); + expect(heading).toHaveClass( + "contents", + "sm:grid", + "sm:grid-cols-[minmax(0,max-content)_auto]", + "sm:items-center", + "sm:justify-start", + "sm:gap-x-4", + ); + expect(heading).toContainElement(title); + expect(heading).toContainElement(description); + expect(heading).toContainElement(headerLink); + expect(title).toHaveClass( + "col-start-2", + "row-start-1", + "min-w-0", + "truncate", + ); + expect(description).toHaveClass("col-span-2", "row-start-2"); + expect(headerLink).toHaveClass("col-span-2", "row-start-3"); + expect(reportAction).toHaveClass( + "col-span-2", + "row-start-4", + "sm:col-start-3", + ); + + const orderedElements = [ + logo, + title, + description, + headerLink, + reportAction, + ]; + orderedElements.slice(0, -1).forEach((element, index) => { + expect( + element.compareDocumentPosition(orderedElements[index + 1]!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + }); + + it("places the report directly after information when no header link exists", () => { + // Given / When + const { container } = render( + Custom Framework} + description={

2 accounts aggregated

} + reportAction={} + filters={
Filters
} + totals={{ pass: 1, fail: 2, manual: 3 }} + coverage={
Coverage
} + topFailed={{ sections: [], dataType: "sections" }} + accordionItems={[]} + initialExpandedKeys={[]} + />, + ); + + // Then + expect( + container.querySelector('[data-slot="aggregated-compliance-title"]'), + ).toHaveClass("col-start-1", "row-start-1"); + expect( + container.querySelector( + '[data-slot="aggregated-compliance-description"]', + ), + ).toHaveClass("col-start-1", "row-start-2"); + expect( + container.querySelector( + '[data-slot="aggregated-compliance-report-action"]', + ), + ).toHaveClass("col-start-1", "row-start-3"); + expect( + container.querySelector( + '[data-slot="aggregated-compliance-header-link"]', + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx new file mode 100644 index 0000000000..1a6380cee5 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx @@ -0,0 +1,160 @@ +import Image from "next/image"; +import type { ComponentProps, ReactNode } from "react"; + +import { + ClientAccordionWrapper, + RequirementsStatusCard, + TopFailedSectionsCard, +} from "@/components/compliance"; +import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; +import { Card } from "@/components/shadcn/card/card"; +import { cn } from "@/lib/utils"; +import type { RequirementsTotals } from "@/types/compliance"; + +interface AggregatedComplianceDetailProps { + compliancetitle: string; + logoPath?: ComponentProps["src"]; + title: ReactNode; + description: ReactNode; + headerLink?: ReactNode; + reportAction: ReactNode; + filters: ReactNode; + totals: RequirementsTotals; + coverage: ReactNode; + topFailed: ComponentProps; + accordionItems: AccordionItemProps[]; + initialExpandedKeys: string[]; +} + +export const AggregatedComplianceDetail = ({ + compliancetitle, + logoPath, + title, + description, + headerLink, + reportAction, + filters, + totals, + coverage, + topFailed, + accordionItems, + initialExpandedKeys, +}: AggregatedComplianceDetailProps) => ( +
+ +
+
+ {logoPath && ( +
+ {`${compliancetitle} +
+ )} + {headerLink ? ( +
+
+ {title} +
+
+ {description} +
+
+ {headerLink} +
+
+ ) : ( + <> +
+ {title} +
+
+ {description} +
+ + )} +
+ {reportAction} +
+
+ {filters} +
+
+ +
+ + {coverage} + +
+ + +
+); diff --git a/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx b/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx new file mode 100644 index 0000000000..6c71db3822 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx @@ -0,0 +1,85 @@ +import Image from "next/image"; +import type { KeyboardEventHandler, ReactNode } from "react"; + +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Card, CardContent } from "@/components/shadcn/card/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; + +interface AggregatedFrameworkCardProps { + frameworkTitle: string; + formattedTitle: string; + ariaLabel: string; + onActivate: () => void; + subtitle: ReactNode; + tooltip?: string; + children: ReactNode; +} + +export const AggregatedFrameworkCard = ({ + frameworkTitle, + formattedTitle, + ariaLabel, + onActivate, + subtitle, + tooltip, + children, +}: AggregatedFrameworkCardProps) => { + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivate(); + } + }; + const logo = getComplianceIcon(frameworkTitle); + const title = ( +

{formattedTitle}

+ ); + + return ( + + +
+
+ {logo && ( +
+ {`${frameworkTitle} +
+ )} +
+ {tooltip ? ( + + {title} + {tooltip} + + ) : ( + title + )} + {subtitle} +
+
+ {children} +
+
+
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx b/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx new file mode 100644 index 0000000000..e3975b36d7 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import type { CheckProviderTypesMap, Requirement } from "@/types/compliance"; + +interface AggregatedRequirementContentProps { + requirement: Requirement; + framework: string; + scanIds: string[]; + emptyMessage: string; + checkProviders?: CheckProviderTypesMap; +} + +export const AggregatedRequirementContent = ({ + requirement, + framework, + scanIds, + emptyMessage, + checkProviders, +}: AggregatedRequirementContentProps) => { + if (scanIds.length === 0) return

{emptyMessage}

; + + return ( + + ); +}; diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts index 7cb6b8cb39..7f38e2b9d3 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts @@ -1,17 +1,26 @@ -import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; +import { COMPLIANCE_TAB, type ComplianceTab } from "@/types/compliance"; function isComplianceTab(value: string): value is ComplianceTab { return Object.values(COMPLIANCE_TAB).includes(value as ComplianceTab); } -/** Resolves `?tab=` into a valid tab, defaulting to Per Scan so existing - * bookmarks (no query param) keep working. */ -function getComplianceTab(value: string | string[] | undefined): ComplianceTab { - if (typeof value !== "string") { - return COMPLIANCE_TAB.PER_SCAN; +/** Resolves `?tab=` into a valid tab, defaulting to Cross Provider — the + * Multiple Scans tab, which owns the bare `/compliance` route. + * + * A `scanId` with no explicit tab means the link predates the tab split (or + * was shared from Single Scan), so it keeps resolving to Per Scan instead of + * landing on the aggregated view, which ignores the scan entirely. */ +function getComplianceTab( + value: string | string[] | undefined, + scanId?: string | string[] | undefined, +): ComplianceTab { + if (typeof value === "string" && isComplianceTab(value)) { + return value; } - return isComplianceTab(value) ? value : COMPLIANCE_TAB.PER_SCAN; + return typeof scanId === "string" && scanId + ? COMPLIANCE_TAB.PER_SCAN + : COMPLIANCE_TAB.CROSS_PROVIDER; } export { getComplianceTab }; diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx index 531cfbe79d..cedd113870 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -4,8 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; - -import { COMPLIANCE_TAB } from "../_types"; +import { COMPLIANCE_TAB } from "@/types/compliance"; import { CompliancePageTabs } from "./compliance-page-tabs"; import { getComplianceTab } from "./compliance-page-tabs.shared"; @@ -21,14 +20,29 @@ vi.mock("next/navigation", () => ({ })); describe("getComplianceTab", () => { - it("falls back to per-scan for missing or invalid values", () => { - expect(getComplianceTab(undefined)).toBe(COMPLIANCE_TAB.PER_SCAN); - expect(getComplianceTab(["cross-provider"])).toBe(COMPLIANCE_TAB.PER_SCAN); - expect(getComplianceTab("bogus")).toBe(COMPLIANCE_TAB.PER_SCAN); + it("falls back to cross-provider for missing or invalid values", () => { + expect(getComplianceTab(undefined)).toBe(COMPLIANCE_TAB.CROSS_PROVIDER); + expect(getComplianceTab(["per-scan"])).toBe(COMPLIANCE_TAB.CROSS_PROVIDER); + expect(getComplianceTab("bogus")).toBe(COMPLIANCE_TAB.CROSS_PROVIDER); + expect(getComplianceTab("per-scan")).toBe(COMPLIANCE_TAB.PER_SCAN); expect(getComplianceTab("cross-provider")).toBe( COMPLIANCE_TAB.CROSS_PROVIDER, ); }); + + it("keeps pre-split links alive: a bare scanId still opens Single Scan", () => { + expect(getComplianceTab(undefined, "scan-1")).toBe(COMPLIANCE_TAB.PER_SCAN); + expect(getComplianceTab("bogus", "scan-1")).toBe(COMPLIANCE_TAB.PER_SCAN); + // An explicit tab always wins over the inferred one. + expect(getComplianceTab("cross-provider", "scan-1")).toBe( + COMPLIANCE_TAB.CROSS_PROVIDER, + ); + // Empty or repeated scanId carries no selection to honour. + expect(getComplianceTab(undefined, "")).toBe(COMPLIANCE_TAB.CROSS_PROVIDER); + expect(getComplianceTab(undefined, ["scan-1"])).toBe( + COMPLIANCE_TAB.CROSS_PROVIDER, + ); + }); }); describe("CompliancePageTabs", () => { @@ -40,21 +54,8 @@ describe("CompliancePageTabs", () => { useCloudUpgradeStore.getState().closeCloudUpgrade(); }); - it("navigates with ?tab=cross-provider and back to the bare route", async () => { - const user = userEvent.setup(); - const { rerender } = render( - Per scan content
} - crossProviderContent={
Cross provider content
} - />, - ); - - await user.click(screen.getByRole("tab", { name: /cross-provider/i })); - expect(pushMock).toHaveBeenCalledWith("/compliance?tab=cross-provider"); - - rerender( + it("renders Multiple Scans as the first tab", () => { + render( { />, ); - await user.click(screen.getByRole("tab", { name: /per scan/i })); + expect( + screen.getAllByRole("tab").map((tab) => tab.textContent), + ).toStrictEqual(["Multiple Scans", "Single Scan"]); + }); + + it("navigates with ?tab=per-scan and back to the bare route", async () => { + const user = userEvent.setup(); + const { rerender } = render( + Per scan content
} + crossProviderContent={
Cross provider content
} + />, + ); + + await user.click(screen.getByRole("tab", { name: /single scan/i })); + expect(pushMock).toHaveBeenCalledWith("/compliance?tab=per-scan"); + + rerender( + Per scan content
} + crossProviderContent={
Cross provider content
} + />, + ); + + await user.click(screen.getByRole("tab", { name: /multiple scans/i })); expect(pushMock).toHaveBeenCalledWith("/compliance"); }); @@ -79,7 +108,7 @@ describe("CompliancePageTabs", () => { ); const crossProviderTab = screen.getByRole("tab", { - name: /cross-provider/i, + name: /multiple scans/i, }); await user.click(crossProviderTab); diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx index 79e5d8af00..6fc7112bce 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx @@ -10,10 +10,10 @@ import { TabsList, TabsTrigger, } from "@/components/shadcn"; +import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; - -import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; +import { COMPLIANCE_TAB, type ComplianceTab } from "@/types/compliance"; interface CompliancePageTabsProps { activeTab: ComplianceTab; @@ -47,42 +47,43 @@ export const CompliancePageTabs = ({ return; } - // Per Scan renders without the query param so existing bookmarks and - // shared links keep resolving to the default view. - if (typedTab === COMPLIANCE_TAB.PER_SCAN) { - router.push("/compliance"); - } else { - router.push(`/compliance?tab=${typedTab}`); - } + // Multiple Scans is the landing view, so it owns the bare route; Single + // Scan pins `?tab=` to stay linkable. + router.push( + typedTab === COMPLIANCE_TAB.CROSS_PROVIDER + ? "/compliance" + : buildPerScanComplianceHref(), + ); }; return ( - // Same layout spacing as the scans view tabs (scans-page-shell.tsx). - - - Per Scan - Cloud - ) : undefined - } - > - Cross-Provider - - + +
+
+ + Cloud + ) : undefined + } + > + Multiple Scans + + + Single Scan + + +
- - {perScanContent} - - - {crossProviderContent} - + + {crossProviderContent} + + + {perScanContent} + +
); }; diff --git a/ui/app/(prowler)/compliance/_components/cross-account-detail.test.tsx b/ui/app/(prowler)/compliance/_components/cross-account-detail.test.tsx new file mode 100644 index 0000000000..796bb7efcf --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-detail.test.tsx @@ -0,0 +1,150 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getAllProviderGroupsMock, + getAllProvidersMock, + getCrossAccountComplianceOverviewMock, + getLatestCrossAccountPdfMock, +} = vi.hoisted(() => ({ + getAllProviderGroupsMock: vi.fn(), + getAllProvidersMock: vi.fn(), + getCrossAccountComplianceOverviewMock: vi.fn(), + getLatestCrossAccountPdfMock: vi.fn(), +})); + +vi.mock("@/actions/manage-groups/manage-groups", () => ({ + getAllProviderGroups: getAllProviderGroupsMock, +})); + +vi.mock("@/actions/providers", () => ({ + getAllProviders: getAllProvidersMock, +})); + +vi.mock("@/components/icons/compliance/IconCompliance", () => ({ + getComplianceIcon: () => undefined, +})); + +vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ + ProviderTypeIcon: () => null, +})); + +vi.mock("@/components/lighthouse/context-contributor", () => ({ + LighthouseContextContributor: ({ item }: { item: unknown }) => ( + {JSON.stringify(item)} + ), +})); + +vi.mock("@/lib/compliance/compliance-mapper", () => ({ + getComplianceMapper: () => ({ + mapComplianceData: () => [], + getTopFailedSections: () => ({ + items: [], + type: "requirements", + prepopulated: false, + }), + }), +})); + +vi.mock("../_actions/cross-account", () => ({ + getCrossAccountComplianceOverview: getCrossAccountComplianceOverviewMock, + getLatestCrossAccountPdf: getLatestCrossAccountPdfMock, +})); + +vi.mock("../_lib/aggregated-compliance-detail", () => ({ + getAggregatedInitialExpandedKeys: () => [], + getAggregatedRequirementsTotals: () => ({ + pass: 8, + fail: 2, + manual: 1, + }), +})); + +vi.mock("../_lib/cross-account-accordion", () => ({ + toCrossAccountAccordionItems: () => [], +})); + +vi.mock("../_lib/cross-account-adapter", () => ({ + buildAccountExtrasMap: () => new Map(), + computeAccountBreakdown: () => [], + crossAccountToMapperInput: () => ({ + attributesData: {}, + requirementsData: {}, + }), +})); + +vi.mock("../_lib/cross-account-frameworks", () => ({ + parseCrossAccountFilters: () => ({}), +})); + +vi.mock("./aggregated-compliance-detail", () => ({ + AggregatedComplianceDetail: () => ( +
+ ), +})); + +vi.mock("./cross-provider-error-alert", () => ({ + CrossProviderErrorAlert: () =>
, +})); + +vi.mock("./cross-provider-filters", () => ({ + CrossProviderFilters: () =>
, +})); + +vi.mock("./cross-provider-pdf-button", () => ({ + CrossProviderPdfButton: () =>
, +})); + +vi.mock("./provider-coverage-card", () => ({ + ProviderCoverageCard: () =>
, +})); + +import { CrossAccountDetail } from "./cross-account-detail"; + +describe("CrossAccountDetail", () => { + beforeEach(() => { + vi.clearAllMocks(); + getAllProvidersMock.mockResolvedValue({ data: [] }); + getAllProviderGroupsMock.mockResolvedValue({ data: [] }); + getLatestCrossAccountPdfMock.mockResolvedValue(null); + getCrossAccountComplianceOverviewMock.mockResolvedValue({ + status: "success", + response: { + data: { + attributes: { + accounts: [], + framework: "CIS", + name: "CIS AWS Foundations", + scan_ids: ["scan-1"], + version: "2.0", + }, + }, + }, + }); + }); + + it("publishes cross-account compliance context", async () => { + // Given / When + render( + await CrossAccountDetail({ + compliancetitle: "cis-aws-foundations", + complianceId: "cis_aws_2.0", + providerType: "aws", + searchParams: {}, + targetSection: "IAM", + }), + ); + + // Then + expect(screen.getByTestId("aggregated-compliance-detail")).toBeVisible(); + expect(screen.getByTestId("lighthouse-context")).toHaveTextContent( + '"mode":"cross-account"', + ); + expect(screen.getByTestId("lighthouse-context")).toHaveTextContent( + '"section":"IAM"', + ); + expect(screen.getByTestId("lighthouse-context")).toHaveTextContent( + '"totals":{"passed":8,"failed":2,"total":11}', + ); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx new file mode 100644 index 0000000000..9c662558f5 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx @@ -0,0 +1,234 @@ +import { Info } from "lucide-react"; + +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; +import { getAllProviders } from "@/actions/providers"; +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; +import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor"; +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; +import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants"; +import { buildComplianceContext } from "@/lib/lighthouse/context/contributions"; +import { + type KnownProviderType, + PROVIDER_DISPLAY_NAMES, +} from "@/types/providers"; + +import { + getCrossAccountComplianceOverview, + getLatestCrossAccountPdf, +} from "../_actions/cross-account"; +import { + getAggregatedInitialExpandedKeys, + getAggregatedRequirementsTotals, +} from "../_lib/aggregated-compliance-detail"; +import { toCrossAccountAccordionItems } from "../_lib/cross-account-accordion"; +import { + buildAccountExtrasMap, + computeAccountBreakdown, + crossAccountToMapperInput, +} from "../_lib/cross-account-adapter"; +import { parseCrossAccountFilters } from "../_lib/cross-account-frameworks"; +import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; + +import { AggregatedComplianceDetail } from "./aggregated-compliance-detail"; +import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; +import type { + CrossProviderAccountOption, + CrossProviderGroupOption, +} from "./cross-provider-filters"; +import { CrossProviderFilters } from "./cross-provider-filters"; +import { CrossProviderPdfButton } from "./cross-provider-pdf-button"; +import type { CoverageRow } from "./provider-coverage-card"; +import { ProviderCoverageCard } from "./provider-coverage-card"; + +interface CrossAccountDetailProps { + compliancetitle: string; + complianceId: string; + providerType: KnownProviderType; + searchParams: Record; + targetSection?: string; +} + +/** + * Server island for the cross-account detail (`?mode=cross-account`): the + * account-axis sibling of `CrossProviderDetail`. Fetches the roll-up of one + * regular framework across every account of one provider type, funnels it + * through the real framework mapper via the adapter, and renders the same + * summary-charts + accordion layout with per-account augmentations. + */ +export const CrossAccountDetail = async ({ + compliancetitle, + complianceId, + providerType, + searchParams, + targetSection, +}: CrossAccountDetailProps) => { + const filters = parseCrossAccountFilters(searchParams); + + const [overviewResponse, providersData, providerGroupsData] = + await Promise.all([ + getCrossAccountComplianceOverview({ + complianceId, + providerType, + filters, + }), + getAllProviders(), + getAllProviderGroups(), + ]); + + if ( + overviewResponse.status === + CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR + ) { + return ; + } + + if ( + overviewResponse.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR + ) { + return ; + } + + const overviewData = overviewResponse.response.data; + + if (!overviewData?.attributes) { + return ( + + + + No cross-account compliance data was returned for this framework. The + view aggregates the latest completed scan of every account of this + provider type — run a scan to populate it. + + + ); + } + + const attrs = overviewData.attributes; + + // Scoped to the EXACT scans the overview resolved (not the raw filters), + // so an offered "Download latest" always matches the data on screen even + // if an account finished a new scan between the two calls. + const latestPdf = await getLatestCrossAccountPdf({ + complianceId, + providerType, + filters: { ...filters, scanIds: attrs.scan_ids }, + }); + + const mapper = getComplianceMapper(attrs.framework); + const { attributesData, requirementsData } = crossAccountToMapperInput(attrs); + const data = mapper.mapComplianceData(attributesData, requirementsData); + const extras = buildAccountExtrasMap(attrs); + const coverageRows: CoverageRow[] = computeAccountBreakdown(attrs).map( + (entry) => ({ + key: entry.id, + label: entry.label, + iconType: providerType, + pass: entry.pass, + fail: entry.fail, + manual: entry.manual, + score: entry.score, + }), + ); + + const totals = getAggregatedRequirementsTotals(data); + const accordionItems = toCrossAccountAccordionItems( + data, + extras, + attrs.framework, + attrs.accounts, + ); + const topFailedResult = mapper.getTopFailedSections(data); + + const initialExpandedKeys = getAggregatedInitialExpandedKeys( + data, + accordionItems, + targetSection, + ); + + const logoPath = getComplianceIcon(compliancetitle); + + const providerAccounts: CrossProviderAccountOption[] = ( + providersData?.data || [] + ) + .filter((provider) => provider.attributes.provider === providerType) + .map((provider) => ({ + id: provider.id, + label: provider.attributes.alias + ? `${provider.attributes.alias} (${provider.attributes.uid})` + : provider.attributes.uid, + type: provider.attributes.provider, + })); + + const providerGroups: CrossProviderGroupOption[] = ( + providerGroupsData?.data || [] + ).map((group) => ({ id: group.id, name: group.attributes.name })); + + return ( + <> + + + {attrs.name || compliancetitle.split("-").join(" ")} + + } + description={ +

+ + {PROVIDER_DISPLAY_NAMES[providerType]} · {attrs.accounts.length}{" "} + {attrs.accounts.length === 1 ? "account" : "accounts"} aggregated ·{" "} + {attrs.scan_ids.length}{" "} + {attrs.scan_ids.length === 1 ? "scan" : "scans"} +

+ } + reportAction={ + + } + filters={ + + } + totals={totals} + coverage={ + + } + topFailed={{ + sections: topFailedResult.items, + dataType: topFailedResult.type, + prepopulated: topFailedResult.prepopulated, + }} + accordionItems={accordionItems} + initialExpandedKeys={initialExpandedKeys} + /> + + ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx b/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx new file mode 100644 index 0000000000..7760f14e88 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; +import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; + +import { buildCrossAccountDetailHref } from "../_lib/cross-account-frameworks"; +import type { CrossAccountFrameworkEntry } from "../_types"; + +import { AggregatedFrameworkCard } from "./aggregated-framework-card"; + +/** + * Card for a regular per-provider framework in the Cross-Provider tab's + * "across accounts" section. Deliberately lightweight — no roll-up numbers: + * the section only enumerates which frameworks can be viewed across accounts + * (computing every framework's N-account aggregation up front would be one + * heavy roll-up call per card). The detail computes the real aggregation. + */ +export const CrossAccountFrameworkCard = ({ + complianceId, + title, + version, + providerType, + accountCount, +}: CrossAccountFrameworkEntry) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const formattedTitle = `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`; + + const navigateToDetail = () => { + router.push( + buildCrossAccountDetailHref( + { complianceId, title, version, providerType }, + Object.fromEntries(searchParams.entries()), + ), + ); + }; + + return ( + + View across providers + + } + > +
+ + + {PROVIDER_DISPLAY_NAMES[providerType]} + + + {accountCount} providers + +
+
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx new file mode 100644 index 0000000000..a3f9ce0732 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx @@ -0,0 +1,259 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getCompliancesOverview } from "@/actions/compliances"; +import { getAllProviders } from "@/actions/providers"; +import { getScans } from "@/actions/scans"; + +import { CrossAccountOverviewSection } from "./cross-account-overview-section"; + +vi.mock("@/actions/providers", () => ({ + getAllProviders: vi.fn(), +})); + +vi.mock("@/actions/scans", () => ({ + getScans: vi.fn(), +})); + +vi.mock("@/actions/compliances", () => ({ + getCompliancesOverview: vi.fn(), +})); + +vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ + ProviderTypeIcon: () =>