diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index af9cb5d5cf..18fa188083 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -14,6 +14,12 @@ All notable changes to the **Prowler API** are documented in this file. - Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165) +## [1.22.2] (Prowler UNRELEASED) + +### 🔐 Security + +- Bump `flask` to 3.1.3 (CVE-2026-27205) and `werkzeug` to 3.1.6 (CVE-2026-27199) [(#10430)](https://github.com/prowler-cloud/prowler/pull/10430) + --- ## [1.22.1] (Prowler v5.21.1) diff --git a/api/poetry.lock b/api/poetry.lock index 0b0da9c853..95fee4f92a 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "about-time" @@ -2711,24 +2711,6 @@ files = [ {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] -[[package]] -name = "deprecated" -version = "1.3.1" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -groups = ["main"] -files = [ - {file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"}, - {file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"}, -] - -[package.dependencies] -wrapt = ">=1.10,<3" - -[package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] - [[package]] name = "detect-secrets" version = "1.5.0" @@ -2983,7 +2965,7 @@ files = [ [package.dependencies] autopep8 = "*" Django = ">=4.2" -gprof2dot = ">=2017.9.19" +gprof2dot = ">=2017.09.19" sqlparse = "*" [[package]] @@ -3370,14 +3352,14 @@ files = [ [[package]] name = "flask" -version = "3.1.2" +version = "3.1.3" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, - {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, + {file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"}, + {file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"}, ] [package.dependencies] @@ -4591,7 +4573,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -4799,7 +4781,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] mongodb = ["pymongo (==4.15.3)"] msgpack = ["msgpack (==1.1.2)"] pyro = ["pyro4 (==4.82)"] -qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"] +qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"] redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"] slmq = ["softlayer_messaging (>=1.0.3)"] sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] @@ -4820,7 +4802,7 @@ files = [ ] [package.dependencies] -certifi = ">=14.5.14" +certifi = ">=14.05.14" durationpy = ">=0.7" google-auth = ">=1.0.1" oauthlib = ">=3.2.2" @@ -6654,7 +6636,7 @@ files = [ [[package]] name = "prowler" -version = "5.19.0" +version = "5.22.0" description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks." optional = false python-versions = ">3.9.1,<3.13" @@ -6712,6 +6694,7 @@ colorama = "0.4.6" cryptography = "44.0.3" dash = "3.1.1" dash-bootstrap-components = "2.0.3" +defusedxml = ">=0.7.1" detect-secrets = "1.5.0" dulwich = "0.23.0" google-api-python-client = "2.163.0" @@ -6729,7 +6712,7 @@ pandas = "2.2.3" py-iam-expand = "0.1.0" py-ocsf-models = "0.8.1" pydantic = ">=2.0,<3.0" -pygithub = "2.5.0" +pygithub = "2.8.0" python-dateutil = ">=2.9.0.post0,<3.0.0" pytz = "2025.1" schema = "0.7.5" @@ -6737,12 +6720,13 @@ shodan = "1.31.0" slack-sdk = "3.39.0" tabulate = "0.9.0" tzlocal = "5.3.1" +uuid6 = "2024.7.10" [package.source] type = "git" url = "https://github.com/prowler-cloud/prowler.git" reference = "master" -resolved_reference = "b31145616064bd6727139777dca1cea9b977346a" +resolved_reference = "41629137efdec1ade078e4386f738c8e0ffce94b" [[package]] name = "psutil" @@ -7115,22 +7099,21 @@ typing-extensions = ">=4.14.1" [[package]] name = "pygithub" -version = "2.5.0" +version = "2.8.0" description = "Use the full Github API v3" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"}, - {file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"}, + {file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"}, + {file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"}, ] [package.dependencies] -Deprecated = "*" pyjwt = {version = ">=2.4.0", extras = ["crypto"]} pynacl = ">=1.4.0" requests = ">=2.14.0" -typing-extensions = ">=4.0.0" +typing-extensions = ">=4.5.0" urllib3 = ">=1.26.0" [[package]] @@ -7182,7 +7165,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.2.2,<=3.3.0.dev0" +astroid = ">=3.2.2,<=3.3.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, @@ -8196,10 +8179,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a0" +botocore = ">=1.37.4,<2.0a.0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "safety" @@ -8785,14 +8768,14 @@ test = ["pytest", "websockets"] [[package]] name = "werkzeug" -version = "3.1.5" +version = "3.1.6" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, - {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, + {file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"}, + {file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"}, ] [package.dependencies] diff --git a/docs/docs.json b/docs/docs.json index a8fd6dedd6..6fcd387a07 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -225,8 +225,7 @@ "group": "Kubernetes", "pages": [ "user-guide/providers/kubernetes/getting-started-k8s", - "user-guide/providers/kubernetes/misc", - "user-guide/cookbooks/kubernetes-in-cluster" + "user-guide/providers/kubernetes/misc" ] }, { diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 2c8b47daf4..9582f7123a 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Azure MySQL flexible server checks now compare configuration values case-insensitively to avoid false negatives when Azure returns lowercase values [(#10396)](https://github.com/prowler-cloud/prowler/pull/10396) - Azure `vm_backup_enabled` and `vm_sufficient_daily_backup_retention_period` checks now compare VM names case-insensitively to avoid false negatives when Azure stores backup item names in a different case [(#10395)](https://github.com/prowler-cloud/prowler/pull/10395) +- `entra_non_privileged_user_has_mfa` skips disabled users to avoid false positives [(#10426)](https://github.com/prowler-cloud/prowler/pull/10426) --- diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 3a414e5f77..297921370d 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -1587,6 +1587,7 @@ "ap-northeast-1", "ap-south-1", "ap-southeast-2", + "ca-central-1", "eu-central-1", "eu-west-1", "eu-west-2", @@ -1670,20 +1671,8 @@ "budgets": { "regions": { "aws": [ - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1", - "ap-southeast-2", "ca-central-1", - "eu-central-1", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "sa-east-1", "us-east-1", - "us-east-2", - "us-west-1", "us-west-2" ], "aws-cn": [ @@ -3439,7 +3428,6 @@ "datazone": { "regions": { "aws": [ - "af-south-1", "ap-east-1", "ap-northeast-1", "ap-northeast-2", @@ -3452,7 +3440,6 @@ "eu-central-1", "eu-central-2", "eu-north-1", - "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", @@ -6998,6 +6985,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -7022,6 +7010,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -7695,6 +7684,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-north-1", @@ -7932,6 +7922,7 @@ "aws": [ "ap-southeast-2", "eu-west-1", + "eu-west-2", "us-east-1", "us-west-2" ], @@ -8255,6 +8246,7 @@ "ap-east-1", "ap-northeast-1", "ap-northeast-2", + "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", @@ -8270,6 +8262,7 @@ "sa-east-1", "us-east-1", "us-east-2", + "us-west-1", "us-west-2" ], "aws-cn": [], @@ -9877,6 +9870,7 @@ "eu-west-1", "eu-west-2", "il-central-1", + "sa-east-1", "us-east-1", "us-east-2", "us-west-1", diff --git a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py index c86fc02da7..d231a7a6b1 100644 --- a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py @@ -11,7 +11,7 @@ class entra_non_privileged_user_has_mfa(Check): for tenant_domain, users in entra_client.users.items(): for user in users.values(): - if not is_privileged_user( + if user.account_enabled and not is_privileged_user( user, entra_client.directory_roles[tenant_domain] ): report = Check_Report_Azure(metadata=self.metadata(), resource=user) diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index 011ea675b0..eb1d62ac11 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -3,7 +3,9 @@ from asyncio import gather from typing import List, Optional from uuid import UUID +from kiota_abstractions.base_request_configuration import RequestConfiguration from msgraph import GraphServiceClient +from msgraph.generated.users.users_request_builder import UsersRequestBuilder from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -65,9 +67,16 @@ class Entra(AzureService): logger.info("Entra - Getting users...") users = {} try: + request_configuration = RequestConfiguration( + query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( + select=["id", "displayName", "accountEnabled"] + ) + ) for tenant, client in self.clients.items(): users.update({tenant: {}}) - users_response = await client.users.get() + users_response = await client.users.get( + request_configuration=request_configuration + ) registration_details = await self._get_user_registration_details(client) try: @@ -81,6 +90,9 @@ class Entra(AzureService): is_mfa_capable=registration_details.get( user.id, False ), + account_enabled=getattr( + user, "account_enabled", True + ), ) } ) @@ -409,6 +421,7 @@ class User(BaseModel): id: str name: str is_mfa_capable: bool = False + account_enabled: bool = True class DefaultUserRolePermissions(BaseModel): diff --git a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py index 4667b665ed..4d2f289a90 100644 --- a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py @@ -142,6 +142,86 @@ class Test_entra_non_privileged_user_has_mfa: assert result[0].resource_id == user_id assert result[0].subscription == f"Tenant: {DOMAIN}" + def test_entra_disabled_user_no_privileged_no_mfa(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import ( + entra_non_privileged_user_has_mfa, + ) + from prowler.providers.azure.services.entra.entra_service import ( + DirectoryRole, + User, + ) + + user = User( + id=user_id, + name="foo", + is_mfa_capable=False, + account_enabled=False, + ) + + entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} + entra_client.directory_roles = { + DOMAIN: { + "Global Administrator": DirectoryRole(id=str(uuid4()), members=[]) + } + } + + check = entra_non_privileged_user_has_mfa() + result = check.execute() + assert len(result) == 0 + + def test_entra_disabled_user_no_privileged_mfa(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import ( + entra_non_privileged_user_has_mfa, + ) + from prowler.providers.azure.services.entra.entra_service import ( + DirectoryRole, + User, + ) + + user = User( + id=user_id, + name="foo", + is_mfa_capable=True, + account_enabled=False, + ) + + entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} + entra_client.directory_roles = { + DOMAIN: { + "Global Administrator": DirectoryRole(id=str(uuid4()), members=[]) + } + } + + check = entra_non_privileged_user_has_mfa() + result = check.execute() + assert len(result) == 0 + def test_entra_user_privileged_no_mfa(self): entra_client = mock.MagicMock user_id = str(uuid4()) diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index 8e3a25e59f..75ef4f98c4 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -147,6 +147,7 @@ class Test_Entra_Service: assert entra_client.users[DOMAIN]["user-1@tenant1.es"].id == "id-1" assert entra_client.users[DOMAIN]["user-1@tenant1.es"].name == "User 1" assert entra_client.users[DOMAIN]["user-1@tenant1.es"].is_mfa_capable is False + assert entra_client.users[DOMAIN]["user-1@tenant1.es"].account_enabled is True def test_get_authorization_policy(self): entra_client = Entra(set_mocked_azure_provider()) @@ -229,8 +230,8 @@ def test_azure_entra__get_users_handles_pagination(): entra_service = Entra.__new__(Entra) users_page_one = [ - SimpleNamespace(id="user-1", display_name="User 1"), - SimpleNamespace(id="user-2", display_name="User 2"), + SimpleNamespace(id="user-1", display_name="User 1", account_enabled=False), + SimpleNamespace(id="user-2", display_name="User 2", account_enabled=True), ] users_page_two = [ SimpleNamespace(id="user-3", display_name="User 3"), @@ -288,9 +289,18 @@ def test_azure_entra__get_users_handles_pagination(): assert len(users["tenant-1"]) == 3 assert users_builder.get.await_count == 1 + request_configuration = users_builder.get.await_args.kwargs["request_configuration"] + assert request_configuration.query_parameters.select == [ + "id", + "displayName", + "accountEnabled", + ] with_url_mock.assert_called_once_with("next-link") registration_details_builder.get.assert_awaited() registration_details_builder.with_url.assert_not_called() assert users["tenant-1"]["user-1"].is_mfa_capable is True + assert users["tenant-1"]["user-1"].account_enabled is False assert users["tenant-1"]["user-2"].is_mfa_capable is True + assert users["tenant-1"]["user-2"].account_enabled is True assert users["tenant-1"]["user-3"].is_mfa_capable is False + assert users["tenant-1"]["user-3"].account_enabled is True diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 2c17bcc5d6..c20aa5bf99 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.22.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397) + +--- + ## [1.21.0] (Prowler v5.21.0) ### 🚀 Added diff --git a/ui/actions/attack-paths/queries.adapter.test.ts b/ui/actions/attack-paths/queries.adapter.test.ts new file mode 100644 index 0000000000..cd6bafd206 --- /dev/null +++ b/ui/actions/attack-paths/queries.adapter.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + ATTACK_PATH_QUERY_IDS, + type AttackPathCartographySchemaAttributes, + type AttackPathQuery, +} from "@/types/attack-paths"; + +import { buildAttackPathQueries } from "./queries.adapter"; + +const presetQuery: AttackPathQuery = { + type: "attack-paths-scans", + id: "preset-query", + attributes: { + name: "Preset Query", + short_description: "Returns privileged attack paths", + description: "Returns privileged attack paths.", + provider: "aws", + attribution: null, + parameters: [], + }, +}; + +describe("buildAttackPathQueries", () => { + it("prepends a custom query with a schema documentation link", () => { + // Given + const schema: AttackPathCartographySchemaAttributes = { + id: "aws-0.129.0", + provider: "aws", + cartography_version: "0.129.0", + schema_url: + "https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md", + raw_schema_url: + "https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md", + }; + + // When + const result = buildAttackPathQueries([presetQuery], schema); + + // Then + expect(result[0]).toMatchObject({ + id: ATTACK_PATH_QUERY_IDS.CUSTOM, + attributes: { + name: "Custom openCypher query", + short_description: "Write and run your own read-only query", + documentation_link: { + text: "Cartography schema used by Prowler for AWS graphs", + link: schema.schema_url, + }, + }, + }); + expect(result[1]).toEqual(presetQuery); + }); +}); diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts index fd256739e1..016abde60e 100644 --- a/ui/actions/attack-paths/queries.adapter.ts +++ b/ui/actions/attack-paths/queries.adapter.ts @@ -1,7 +1,10 @@ import { MetaDataProps } from "@/types"; import { + ATTACK_PATH_QUERY_IDS, + type AttackPathCartographySchemaAttributes, AttackPathQueriesResponse, AttackPathQuery, + QUERY_PARAMETER_INPUT_TYPES, } from "@/types/attack-paths"; /** @@ -53,3 +56,52 @@ export function adaptAttackPathQueriesResponse( return { data: enrichedData, metadata }; } + +const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n) +RETURN n +LIMIT 25`; + +const formatSchemaDocumentationLinkText = ( + schema: AttackPathCartographySchemaAttributes, +): string => { + return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`; +}; + +const createCustomQuery = ( + schema?: AttackPathCartographySchemaAttributes, +): AttackPathQuery => ({ + type: "attack-paths-scans", + id: ATTACK_PATH_QUERY_IDS.CUSTOM, + attributes: { + name: "Custom openCypher query", + short_description: "Write and run your own read-only query", + description: + "Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.", + provider: "custom", + attribution: null, + documentation_link: schema + ? { + text: formatSchemaDocumentationLinkText(schema), + link: schema.schema_url, + } + : null, + parameters: [ + { + name: "query", + label: "openCypher", + data_type: "string", + description: "", + placeholder: CUSTOM_QUERY_PLACEHOLDER, + required: true, + input_type: QUERY_PARAMETER_INPUT_TYPES.TEXTAREA, + }, + ], + }, +}); + +export const buildAttackPathQueries = ( + queries: AttackPathQuery[], + schema?: AttackPathCartographySchemaAttributes, +): AttackPathQuery[] => { + return [createCustomQuery(schema), ...queries]; +}; diff --git a/ui/actions/attack-paths/queries.test.ts b/ui/actions/attack-paths/queries.test.ts index 6c2be5f15d..ab3afc447f 100644 --- a/ui/actions/attack-paths/queries.test.ts +++ b/ui/actions/attack-paths/queries.test.ts @@ -17,7 +17,11 @@ vi.mock("@/lib/server-actions-helper", () => ({ handleApiResponse: handleApiResponseMock, })); -import { executeQuery } from "./queries"; +import { + executeCustomQuery, + executeQuery, + getCartographySchema, +} from "./queries"; describe("executeQuery", () => { beforeEach(() => { @@ -65,3 +69,139 @@ describe("executeQuery", () => { expect(handleApiResponseMock).not.toHaveBeenCalled(); }); }); + +describe("executeCustomQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ + data: { + type: "attack-paths-query-run-requests", + id: null, + attributes: { + nodes: [], + relationships: [], + }, + }, + }); + }); + + it("posts the custom query to the dedicated endpoint", async () => { + // Given + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + + // When + await executeCustomQuery( + "550e8400-e29b-41d4-a716-446655440000", + "MATCH (n) RETURN n LIMIT 10", + ); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/queries/custom", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + data: { + type: "attack-paths-custom-query-run-requests", + attributes: { + query: "MATCH (n) RETURN n LIMIT 10", + }, + }, + }), + }), + ); + }); + + it("rejects empty custom queries before calling the API", async () => { + // When + const result = await executeCustomQuery( + "550e8400-e29b-41d4-a716-446655440000", + " ", + ); + + // Then + expect(result).toEqual({ + error: "Custom query cannot be empty", + status: 400, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); + + it("rejects custom queries longer than 10000 characters before calling the API", async () => { + // When + const result = await executeCustomQuery( + "550e8400-e29b-41d4-a716-446655440000", + "x".repeat(10001), + ); + + // Then + expect(result).toEqual({ + error: "Custom query must be 10000 characters or fewer", + status: 400, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); + + it("rejects custom queries with write operations before calling the API", async () => { + // When + const result = await executeCustomQuery( + "550e8400-e29b-41d4-a716-446655440000", + "MATCH (n) SET n.name = 'updated' RETURN n", + ); + + // Then + expect(result).toEqual({ + error: "Only read-only queries are allowed", + status: 400, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); +}); + +describe("getCartographySchema", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("fetches the schema metadata for the selected scan", async () => { + // Given + const apiResponse = { + data: { + type: "attack-paths-cartography-schemas", + id: "aws-0.129.0", + attributes: { + id: "aws-0.129.0", + provider: "aws", + cartography_version: "0.129.0", + schema_url: + "https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md", + raw_schema_url: + "https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md", + }, + }, + }; + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + handleApiResponseMock.mockResolvedValue(apiResponse); + + // When + const result = await getCartographySchema( + "550e8400-e29b-41d4-a716-446655440000", + ); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/schema", + expect.objectContaining({ + method: "GET", + }), + ); + expect(result).toEqual(apiResponse); + }); +}); diff --git a/ui/actions/attack-paths/queries.ts b/ui/actions/attack-paths/queries.ts index bc9068d52e..228c1cca0c 100644 --- a/ui/actions/attack-paths/queries.ts +++ b/ui/actions/attack-paths/queries.ts @@ -3,12 +3,16 @@ import { z } from "zod"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { customAttackPathQuerySchema } from "@/lib/attack-paths/custom-query"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { + AttackPathCartographySchema, + AttackPathCartographySchemaResponse, AttackPathQueriesResponse, AttackPathQuery, AttackPathQueryError, AttackPathQueryResult, + ExecuteCustomQueryRequest, ExecuteQueryRequest, } from "@/types/attack-paths"; @@ -102,3 +106,93 @@ export const executeQuery = async ( }; } }; + +/** + * Execute a custom openCypher query on an attack path scan + */ +export const executeCustomQuery = async ( + scanId: string, + query: string, +): Promise => { + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const validatedQuery = customAttackPathQuerySchema.safeParse(query); + if (!validatedQuery.success) { + return { + error: + validatedQuery.error.issues[0]?.message ?? "Custom query is invalid.", + status: 400, + }; + } + + const headers = await getAuthHeaders({ contentType: true }); + + const requestBody: ExecuteCustomQueryRequest = { + data: { + type: "attack-paths-custom-query-run-requests", + attributes: { + query: validatedQuery.data, + }, + }, + }; + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries/custom`, + { + headers, + method: "POST", + body: JSON.stringify(requestBody), + }, + ); + + return (await handleApiResponse(response)) as + | AttackPathQueryResult + | AttackPathQueryError; + } catch (error) { + console.error("Error executing custom query on scan:", error); + return { + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + status: 503, + }; + } +}; + +/** + * Fetch cartography schema metadata for a specific attack path scan + */ +export const getCartographySchema = async ( + scanId: string, +): Promise<{ data: AttackPathCartographySchema } | undefined> => { + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/schema`, + { + headers, + method: "GET", + }, + ); + + const apiResponse = (await handleApiResponse( + response, + )) as AttackPathCartographySchemaResponse; + + return { data: apiResponse.data }; + } catch (error) { + console.error("Error fetching cartography schema for scan:", error); + return undefined; + } +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts index eac86fccc7..83161cc4ef 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts @@ -1,6 +1,8 @@ export { ExecuteButton } from "./execute-button"; export * from "./graph"; export * from "./node-detail"; +export { QueryDescription } from "./query-description"; +export { QueryExecutionError } from "./query-execution-error"; export { QueryParametersForm } from "./query-parameters-form"; export { QuerySelector } from "./query-selector"; export { ScanListTable } from "./scan-list-table"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx new file mode 100644 index 0000000000..cc61c16a4d --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { AttackPathQuery } from "@/types/attack-paths"; + +import { QueryDescription } from "./query-description"; + +const customQuery: AttackPathQuery = { + type: "attack-paths-scans", + id: "custom-query", + attributes: { + name: "Custom openCypher query", + short_description: "Write your own query", + description: + "Run a read-only openCypher query against the selected Attack Paths scan.", + provider: "aws", + attribution: null, + documentation_link: { + text: "Cartography schema used by Prowler for AWS graphs", + link: "https://example.com/schema", + }, + parameters: [], + }, +}; + +describe("QueryDescription", () => { + it("renders the schema documentation link inside an info alert", () => { + // Given + render(); + + // When + const alert = screen.getByRole("alert"); + const link = screen.getByRole("link", { + name: /cartography schema used by prowler for aws graphs/i, + }); + + // Then + expect(alert).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "https://example.com/schema"); + }); + + it("does not render unsafe documentation or attribution URLs as clickable links", () => { + // Given + const queryWithUnsafeLinks: AttackPathQuery = { + ...customQuery, + attributes: { + ...customQuery.attributes, + documentation_link: { + text: "Cartography schema used by Prowler for AWS graphs", + link: "javascript:alert('xss')", + }, + attribution: { + text: "Unsafe source", + link: "javascript:alert('xss')", + }, + }, + }; + + // When + render(); + + // Then + expect( + screen.queryByRole("link", { + name: /cartography schema used by prowler for aws graphs/i, + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: /unsafe source/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/cartography schema used by prowler for aws graphs/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 new file mode 100644 index 0000000000..d1cb6e85fe --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx @@ -0,0 +1,70 @@ +import { Info } from "lucide-react"; + +import { Alert, AlertDescription } from "@/components/shadcn"; +import type { AttackPathQuery } from "@/types/attack-paths"; + +interface QueryDescriptionProps { + query: AttackPathQuery; +} + +const isSafeUrl = (url: string): boolean => { + try { + const parsedUrl = new URL(url); + return parsedUrl.protocol === "https:" || parsedUrl.protocol === "http:"; + } catch { + return false; + } +}; + +export const QueryDescription = ({ query }: QueryDescriptionProps) => { + const documentationLink = query.attributes.documentation_link; + const attribution = query.attributes.attribution; + + return ( + + + +

{query.attributes.description}

+ + {documentationLink && ( +

+ {isSafeUrl(documentationLink.link) ? ( + + {documentationLink.text} + + ) : ( + {documentationLink.text} + )} +

+ )} + + {attribution && ( +

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

+ )} +
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx new file mode 100644 index 0000000000..1c7fb29712 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { QueryExecutionError } from "./query-execution-error"; + +describe("QueryExecutionError", () => { + it("renders the default title and the raw query error details without extra copy", () => { + // Given + const error = + "Invalid input 'WHERE': expected 'MATCH' or 'WITH' (line 1, column 1)"; + + // When + render(); + + // Then + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/query execution failed/i)).toBeInTheDocument(); + expect( + screen.queryByText(/the attack paths query could not be executed/i), + ).not.toBeInTheDocument(); + expect(screen.getByText(error)).toBeInTheDocument(); + }); + + it("renders custom title and description when provided", () => { + // Given + const error = "Failed to load available queries"; + + // When + render( + , + ); + + // Then + expect(screen.getByText(/failed to load queries/i)).toBeInTheDocument(); + expect( + screen.getByText( + /available attack paths queries could not be loaded for this scan/i, + ), + ).toBeInTheDocument(); + expect(screen.getByText(error)).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx new file mode 100644 index 0000000000..084bcf6e16 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx @@ -0,0 +1,30 @@ +import { CircleAlert } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn"; + +interface QueryExecutionErrorProps { + error: string; + title?: string; + description?: string; +} + +export const QueryExecutionError = ({ + error, + title = "Query execution failed", + description, +}: QueryExecutionErrorProps) => { + return ( + + + {title} + + {description ?

{description}

: null} +
+
+            {error}
+          
+
+
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx index ac2b81be7f..694da8babe 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx @@ -1,8 +1,12 @@ import { render, screen } from "@testing-library/react"; +import { useEffect } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { describe, expect, it } from "vitest"; -import type { AttackPathQuery } from "@/types/attack-paths"; +import { + ATTACK_PATH_QUERY_IDS, + type AttackPathQuery, +} from "@/types/attack-paths"; import { QueryParametersForm } from "./query-parameters-form"; @@ -42,6 +46,64 @@ function TestForm() { ); } +function TestCustomQueryForm() { + const customQuery: AttackPathQuery = { + type: "attack-paths-scans", + id: ATTACK_PATH_QUERY_IDS.CUSTOM, + attributes: { + name: "Custom openCypher query", + short_description: "Write your own query", + description: "Run a custom query against the graph.", + provider: "aws", + attribution: null, + parameters: [ + { + name: "query", + label: "openCypher", + data_type: "string", + input_type: "textarea", + placeholder: "MATCH (n) RETURN n LIMIT 25", + description: "", + required: true, + }, + ], + }, + }; + + const form = useForm({ + defaultValues: { + query: "", + }, + }); + + return ( + + + + ); +} + +function TestFormWithError() { + const form = useForm({ + defaultValues: { + tag_key: "", + }, + }); + + useEffect(() => { + form.setError("tag_key", { + type: "manual", + message: "Tag key is required", + }); + }, [form]); + + return ( + + + + ); +} + describe("QueryParametersForm", () => { it("uses the field description as the placeholder instead of rendering helper text below", () => { // Given @@ -70,4 +132,62 @@ describe("QueryParametersForm", () => { screen.queryByText("Tag key to filter the S3 bucket."), ).not.toBeInTheDocument(); }); + + it("renders a textarea when the parameter input type is textarea", () => { + // Given + render(); + + // When + const input = screen.getByRole("textbox", { name: /opencypher/i }); + const codeEditor = screen.getByTestId("query-code-editor"); + + // Then + expect(input.tagName).toBe("TEXTAREA"); + expect(input).toHaveAttribute("data-slot", "textarea"); + expect(input).toHaveAttribute("placeholder", "MATCH (n) RETURN n LIMIT 25"); + expect(input).toHaveAttribute("spellcheck", "false"); + expect(input).toHaveAttribute("autocomplete", "off"); + expect(input).toHaveAttribute("autocorrect", "off"); + expect(input).toHaveAttribute("autocapitalize", "none"); + expect(input).toHaveClass( + "minimal-scrollbar", + "min-h-[320px]", + "font-mono", + "leading-6", + ); + expect(codeEditor).toHaveClass( + "rounded-xl", + "border", + "bg-bg-neutral-primary", + ); + expect(screen.getByText("Read-only")).toBeInTheDocument(); + }); + + it("uses the design-system error token for field validation messages", async () => { + // Given + render(); + + // When + const errorMessage = await screen.findByText("Tag key is required"); + + // Then + expect(errorMessage).toHaveClass("text-text-error-primary", "text-xs"); + }); + + it("connects field errors to the input for accessibility", async () => { + // Given + render(); + + // When + const input = screen.getByRole("textbox", { name: /tag key/i }); + const errorMessage = await screen.findByText("Tag key is required"); + + // Then + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(errorMessage).toHaveAttribute("id"); + expect(input).toHaveAttribute( + "aria-describedby", + expect.stringContaining(errorMessage.getAttribute("id") ?? ""), + ); + }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx index 24937c0b7b..6ac5883e66 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx @@ -1,9 +1,21 @@ "use client"; -import { Controller, useFormContext } from "react-hook-form"; +import { useFormContext } from "react-hook-form"; -import { Input } from "@/components/shadcn"; -import type { AttackPathQuery } from "@/types/attack-paths"; +import { Input, Textarea } from "@/components/shadcn"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { cn } from "@/lib/utils"; +import { + ATTACK_PATH_QUERY_IDS, + type AttackPathQuery, + QUERY_PARAMETER_INPUT_TYPES, +} from "@/types/attack-paths"; interface QueryParametersFormProps { selectedQuery: AttackPathQuery | null | undefined; @@ -16,10 +28,7 @@ interface QueryParametersFormProps { export const QueryParametersForm = ({ selectedQuery, }: QueryParametersFormProps) => { - const { - control, - formState: { errors }, - } = useFormContext(); + const { control } = useFormContext(); if (!selectedQuery || !selectedQuery.attributes.parameters.length) { return null; @@ -36,23 +45,26 @@ export const QueryParametersForm = ({ className="grid grid-cols-1 gap-4 md:grid-cols-2" > {selectedQuery.attributes.parameters.map((param) => ( - { + render={({ field, fieldState }) => { if (param.data_type === "boolean") { return ( -
+ -
+ + ); } - const errorMessage = (() => { - const error = errors[param.name]; - if (error && typeof error.message === "string") { - return error.message; - } - return undefined; - })(); + const placeholder = + param.description || + param.placeholder || + `Enter ${param.label.toLowerCase()}`; + + const isTextarea = + param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA; + const isCustomCodeEditor = + selectedQuery.id === ATTACK_PATH_QUERY_IDS.CUSTOM && + param.name === "query" && + isTextarea; return ( -
- - - {errorMessage && ( - {errorMessage} + + > + {!isCustomCodeEditor && ( + + {param.label} + {param.required && ( + * + )} + + )} + {isCustomCodeEditor ? ( +
+
+ + {param.label} + {param.required && ( + * + )} + + + Read-only + +
+ +