diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 17acf2155a..3fa6952f3d 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,11 +2,22 @@
All notable changes to the **Prowler API** are documented in this file.
-## [1.23.0] (Prowler UNRELEASED)
+## [1.23.0] (Prowler v5.22.0)
+
+### 🚀 Added
+
+- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377)
+
+### 🐞 Fixed
+
+- Finding groups latest endpoint now aggregates the latest snapshot per provider before check-level totals, keeping impacted resources aligned across providers [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
+- Mute rule creation now triggers finding-group summary re-aggregation after historical muting, keeping stats in sync after mute operations [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
+- Attack Paths: Deduplicate nodes before ProwlerFinding lookup in Attack Paths Cypher queries, reducing execution time [(#10424)](https://github.com/prowler-cloud/prowler/pull/10424)
### 🔐 Security
- 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)
+- 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)
---
@@ -23,7 +34,6 @@ All notable changes to the **Prowler API** are documented in this file.
### 🚀 Added
- `CORS_ALLOWED_ORIGINS` configurable via environment variable [(#10355)](https://github.com/prowler-cloud/prowler/pull/10355)
-- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377)
- Attack Paths: Tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308)
### 🔄 Changed
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/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py
index 3dac99a767..b874de74bd 100644
--- a/api/src/backend/api/attack_paths/queries/aws.py
+++ b/api/src/backend/api/attack_paths/queries/aws.py
@@ -33,10 +33,16 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(ec2)
- UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n
+ WITH collect(path_s3) + collect(path_ec2) + collect(path_role) + collect(path_assume_role) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[
AttackPathsQueryParameterDefinition(
@@ -67,10 +73,15 @@ AWS_RDS_INSTANCES = AttackPathsQueryDefinition(
cypher=f"""
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance)
- UNWIND nodes(path) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -85,10 +96,15 @@ AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition(
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance)
WHERE rds.storage_encrypted = false
- UNWIND nodes(path) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -103,10 +119,15 @@ AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition(
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)
WHERE s3.anonymous_access = true
- UNWIND nodes(path) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -122,10 +143,15 @@ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition(
WHERE stmt.effect = 'Allow'
AND any(x IN stmt.action WHERE x = '*')
- UNWIND nodes(path) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -141,10 +167,15 @@ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition(
WHERE stmt.effect = 'Allow'
AND any(x IN stmt.action WHERE x = "iam:DeletePolicy")
- UNWIND nodes(path) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -160,10 +191,15 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition(
WHERE stmt.effect = "Allow"
AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create")
- UNWIND nodes(path) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -186,10 +222,15 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition(
OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(ec2)
- UNWIND nodes(path) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
)
@@ -203,17 +244,21 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition(
cypher=f"""
OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}})
- // Match EC2 instances that are internet-exposed with open security groups (0.0.0.0/0)
- MATCH path_ec2 = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange)
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange)
WHERE ec2.exposed_internet = true
AND ir.range = "0.0.0.0/0"
OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(ec2)
- UNWIND nodes(path_ec2) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_ec2, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
)
@@ -232,10 +277,15 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition(
OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(elb)
- UNWIND nodes(path) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
)
@@ -254,10 +304,15 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition(
OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(elbv2)
- UNWIND nodes(path) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
)
@@ -279,10 +334,15 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition(
OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(x)
- UNWIND nodes(path) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[
AttackPathsQueryParameterDefinition(
@@ -336,11 +396,16 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -369,11 +434,16 @@ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition(
// Find existing App Runner services with roles attached (potential targets)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}})
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -434,11 +504,16 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -476,11 +551,16 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition(
// Find roles that trust Bedrock service (already attached to existing code interpreters)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}})
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -523,11 +603,16 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -556,11 +641,16 @@ AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition(
// Find roles that trust CloudFormation service (already attached to existing stacks)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -612,11 +702,16 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -659,11 +754,16 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -701,11 +801,16 @@ AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition(
// Find roles that trust CloudFormation service (already attached to existing stacks)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -757,11 +862,16 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -790,11 +900,16 @@ AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition(
// Find roles that trust CodeBuild service (already attached to existing projects)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -823,11 +938,16 @@ AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition(
// Find roles that trust CodeBuild service (already attached to existing projects)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -879,11 +999,16 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -945,11 +1070,16 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -992,11 +1122,16 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1043,11 +1178,16 @@ AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition(
// Find EC2 instances with instance profiles (potential targets)
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1090,11 +1230,16 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1132,11 +1277,16 @@ AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition(
// Find launch templates in the account (potential targets)
MATCH path_target = (aws)--(template:LaunchTemplate)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1165,11 +1315,16 @@ AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition(
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1230,11 +1385,16 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1295,11 +1455,16 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1351,11 +1516,16 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefin
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1407,11 +1577,16 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1463,11 +1638,16 @@ AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinitio
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1505,11 +1685,16 @@ AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition(
// Find roles that trust ECS tasks service (already attached to running tasks)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1552,11 +1737,16 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1585,11 +1775,16 @@ AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition(
// Find roles that trust Glue service (already attached to existing dev endpoints)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1641,11 +1836,16 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1697,11 +1897,16 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1753,11 +1958,16 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1809,11 +2019,16 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1847,11 +2062,16 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition(
OR target_policy.arn CONTAINS resource
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1885,11 +2105,16 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
OR resource CONTAINS target_user.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1937,11 +2162,16 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
OR resource CONTAINS target_user.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1975,11 +2205,16 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
OR resource CONTAINS target_user.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -1997,7 +2232,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find roles with iam:PutRolePolicy permission scoped to themselves
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND any(action IN stmt.action WHERE
toLower(action) = 'iam:putrolepolicy'
@@ -2010,11 +2245,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition(
OR resource CONTAINS role.name
)
- UNWIND nodes(path_principal) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2048,11 +2288,16 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
OR resource CONTAINS target_user.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2070,7 +2315,7 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find users with iam:PutUserPolicy permission scoped to themselves
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND any(action IN stmt.action WHERE
toLower(action) = 'iam:putuserpolicy'
@@ -2083,11 +2328,16 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition(
OR resource CONTAINS user.name
)
- UNWIND nodes(path_principal) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2105,7 +2355,7 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find users with iam:AttachUserPolicy permission scoped to themselves
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND any(action IN stmt.action WHERE
toLower(action) = 'iam:attachuserpolicy'
@@ -2118,11 +2368,16 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition(
OR resource CONTAINS user.name
)
- UNWIND nodes(path_principal) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2140,7 +2395,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find roles with iam:AttachRolePolicy permission scoped to themselves
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND any(action IN stmt.action WHERE
toLower(action) = 'iam:attachrolepolicy'
@@ -2153,11 +2408,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition(
OR resource CONTAINS role.name
)
- UNWIND nodes(path_principal) as n
+ WITH collect(path) 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2191,11 +2451,16 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition(
OR resource CONTAINS target_group.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2229,11 +2494,16 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition(
OR resource CONTAINS target_group.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2267,11 +2537,16 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2305,11 +2580,16 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition(
OR resource CONTAINS target_group.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2343,11 +2623,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2395,11 +2680,16 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio
OR resource CONTAINS target_user.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2434,11 +2724,16 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition(
OR target_policy.arn CONTAINS resource
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2472,11 +2767,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2524,11 +2824,16 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
OR resource CONTAINS target_user.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2576,11 +2881,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2629,11 +2939,16 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin
OR target_policy.arn CONTAINS resource
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2681,11 +2996,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2737,11 +3057,16 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2793,11 +3118,16 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2831,11 +3161,16 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition(
OR resource CONTAINS lambda_fn.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2883,11 +3218,16 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition(
OR resource CONTAINS lambda_fn.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2935,11 +3275,16 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinit
OR resource CONTAINS lambda_fn.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -2991,11 +3336,16 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -3038,11 +3388,16 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -3085,11 +3440,16 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -3132,11 +3492,16 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -3170,11 +3535,16 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition(
OR resource CONTAINS notebook.notebook_instance_name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -3235,11 +3605,16 @@ AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition(
OR resource CONTAINS notebook.notebook_instance_name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -3268,11 +3643,16 @@ AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition(
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -3301,11 +3681,16 @@ AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition(
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
@@ -3339,11 +3724,16 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition(
OR resource CONTAINS target_role.name
)
- UNWIND nodes(path_principal) + nodes(path_target) as n
+ 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]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}})
- RETURN path_principal, path_target,
- collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py
index 7dd14bf420..b2e82d2ea1 100644
--- a/api/src/backend/api/tests/test_views.py
+++ b/api/src/backend/api/tests/test_views.py
@@ -45,6 +45,7 @@ from api.models import (
ComplianceRequirementOverview,
DailySeveritySummary,
Finding,
+ FindingGroupDailySummary,
Integration,
Invitation,
LighthouseProviderConfiguration,
@@ -14689,10 +14690,16 @@ class TestMuteRuleViewSet:
assert len(data) == 2
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
- @patch("tasks.tasks.mute_historical_findings_task.apply_async")
+ @patch("api.v1.views.chain")
+ @patch("api.v1.views.aggregate_finding_group_summaries_task.si")
+ @patch("api.v1.views.mute_historical_findings_task.si")
+ @patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
def test_mute_rules_create_valid(
self,
- mock_task,
+ _mock_on_commit,
+ mock_mute_signature,
+ mock_aggregate_signature,
+ mock_chain,
authenticated_client,
findings_fixture,
create_test_user,
@@ -14730,8 +14737,14 @@ class TestMuteRuleViewSet:
assert finding.muted_at is not None
assert finding.muted_reason == "Security exception approved"
- # Verify background task was called
- mock_task.assert_called_once()
+ # Verify background task chain was called
+ mock_mute_signature.assert_called_once()
+ mock_aggregate_signature.assert_called_once()
+ mock_chain.assert_called_once_with(
+ mock_mute_signature.return_value,
+ mock_aggregate_signature.return_value,
+ )
+ mock_chain.return_value.apply_async.assert_called_once()
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
def test_mute_rules_create_converts_finding_ids_to_uids(
@@ -15840,6 +15853,48 @@ class TestFindingGroupViewSet:
assert len(data) == 1
assert data[0]["id"] == "cloudtrail_enabled"
+ def test_finding_groups_latest_aggregates_latest_per_provider(
+ self, authenticated_client, providers_fixture
+ ):
+ """Test /latest aggregates latest summary from each provider for the same check."""
+ provider1 = providers_fixture[0]
+ provider2 = providers_fixture[1]
+
+ check_id = "cross_provider_latest_resources_total"
+ now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
+
+ FindingGroupDailySummary.objects.create(
+ tenant_id=provider1.tenant_id,
+ provider=provider1,
+ check_id=check_id,
+ inserted_at=now - timedelta(days=1),
+ resources_total=20,
+ resources_fail=20,
+ fail_count=20,
+ )
+ FindingGroupDailySummary.objects.create(
+ tenant_id=provider2.tenant_id,
+ provider=provider2,
+ check_id=check_id,
+ inserted_at=now,
+ resources_total=7,
+ resources_fail=7,
+ fail_count=7,
+ )
+
+ response = authenticated_client.get(
+ reverse("finding-group-latest"),
+ {"filter[check_id]": check_id},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert len(data) == 1
+ attrs = data[0]["attributes"]
+ assert attrs["resources_total"] == 27
+ assert attrs["resources_fail"] == 27
+ assert attrs["fail_count"] == 27
+
def test_finding_groups_latest_provider_type_filter(
self, authenticated_client, finding_groups_fixture
):
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index 7c2de4a41c..85c3062965 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -16,6 +16,7 @@ from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
+from celery import chain
from celery.result import AsyncResult
from config.custom_logging import BackendLogger
from config.env import env
@@ -81,6 +82,7 @@ from tasks.beat import schedule_provider_scan
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.export import get_s3_client
from tasks.tasks import (
+ aggregate_finding_group_summaries_task,
backfill_compliance_summaries_task,
backfill_scan_resource_summaries_task,
check_integration_connection_task,
@@ -6725,10 +6727,25 @@ class MuteRuleViewSet(BaseRLSViewSet):
)
# Launch background task for historical muting
- with transaction.atomic():
- mute_historical_findings_task.apply_async(
- kwargs={"tenant_id": tenant_id, "mute_rule_id": str(mute_rule.id)}
- )
+ latest_scan_id = (
+ Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
+ .order_by("-completed_at", "-inserted_at")
+ .values_list("id", flat=True)
+ .first()
+ )
+
+ transaction.on_commit(
+ lambda: chain(
+ mute_historical_findings_task.si(
+ tenant_id=tenant_id,
+ mute_rule_id=str(mute_rule.id),
+ ),
+ aggregate_finding_group_summaries_task.si(
+ tenant_id=tenant_id,
+ scan_id=str(latest_scan_id),
+ ),
+ ).apply_async()
+ )
# Return the created mute rule
serializer = self.get_serializer(mute_rule)
@@ -7210,13 +7227,15 @@ class FindingGroupViewSet(BaseRLSViewSet):
raise ValidationError(filterset.errors)
filtered_queryset = filterset.qs
- # Keep only rows from the latest inserted_at date per check_id
- latest_per_check = filtered_queryset.annotate(
- latest_inserted_at=Window(
- expression=Max("inserted_at"),
- partition_by=[F("check_id")],
- )
- ).filter(inserted_at=F("latest_inserted_at"))
+ # Keep only the latest row per (check_id, provider), then aggregate by check_id.
+ latest_per_check_ids = (
+ filtered_queryset.order_by("check_id", "provider_id", "-inserted_at")
+ .distinct("check_id", "provider_id")
+ .values("id")
+ )
+ latest_per_check = filtered_queryset.filter(
+ id__in=Subquery(latest_per_check_ids)
+ )
# Re-aggregate daily summaries
aggregated_queryset = self._aggregate_daily_summaries(latest_per_check)
diff --git a/docs/docs.json b/docs/docs.json
index 3ec056989f..6fcd387a07 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -304,6 +304,13 @@
"pages": [
"user-guide/compliance/tutorials/threatscore"
]
+ },
+ {
+ "group": "Cookbooks",
+ "pages": [
+ "user-guide/cookbooks/kubernetes-in-cluster",
+ "user-guide/cookbooks/cicd-pipeline"
+ ]
}
]
},
diff --git a/docs/images/providers/googleworkspace-check-connection.png b/docs/images/providers/googleworkspace-check-connection.png
new file mode 100644
index 0000000000..149f6b33a5
Binary files /dev/null and b/docs/images/providers/googleworkspace-check-connection.png differ
diff --git a/docs/images/providers/googleworkspace-credentials-form.png b/docs/images/providers/googleworkspace-credentials-form.png
new file mode 100644
index 0000000000..bc85df96b6
Binary files /dev/null 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
new file mode 100644
index 0000000000..ced135e5eb
Binary files /dev/null and b/docs/images/providers/googleworkspace-customer-id-form.png differ
diff --git a/docs/images/providers/googleworkspace-customer-id.png b/docs/images/providers/googleworkspace-customer-id.png
new file mode 100644
index 0000000000..4d46e2e4f4
Binary files /dev/null and b/docs/images/providers/googleworkspace-customer-id.png differ
diff --git a/docs/images/providers/googleworkspace-launch-scan.png b/docs/images/providers/googleworkspace-launch-scan.png
new file mode 100644
index 0000000000..62e9054a23
Binary files /dev/null and b/docs/images/providers/googleworkspace-launch-scan.png differ
diff --git a/docs/images/providers/select-googleworkspace-prowler-cloud.png b/docs/images/providers/select-googleworkspace-prowler-cloud.png
new file mode 100644
index 0000000000..b8a83b6e83
Binary files /dev/null and b/docs/images/providers/select-googleworkspace-prowler-cloud.png differ
diff --git a/docs/user-guide/cookbooks/cicd-pipeline.mdx b/docs/user-guide/cookbooks/cicd-pipeline.mdx
new file mode 100644
index 0000000000..9dffd5049c
--- /dev/null
+++ b/docs/user-guide/cookbooks/cicd-pipeline.mdx
@@ -0,0 +1,243 @@
+---
+title: 'Run Prowler in CI/CD and Send Findings to Prowler Cloud'
+---
+
+This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). Examples cover GitHub Actions and GitLab CI.
+
+## Prerequisites
+
+* A **Prowler Cloud** account with an active subscription (see [Prowler Cloud Pricing](https://prowler.com/pricing))
+* A Prowler Cloud **API key** with the **Manage Ingestions** permission (see [API Keys](/user-guide/tutorials/prowler-app-api-keys))
+* Cloud provider credentials configured in the CI/CD environment (e.g., AWS credentials for scanning AWS accounts)
+* Access to configure pipeline workflows and secrets in the CI/CD platform
+
+## Key Concepts
+
+Prowler CLI provides the `--push-to-cloud` flag, which uploads scan results directly to Prowler Cloud after a scan completes. Combined with the `PROWLER_CLOUD_API_KEY` environment variable, this enables fully automated ingestion without manual file uploads.
+
+For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation.
+
+
+The examples in this guide use AWS as the target provider, but the same approach applies to any provider supported by Prowler (Azure, GCP, Kubernetes, and others). Replace `prowler aws` with the desired provider command (e.g., `prowler gcp`, `prowler azure`) and configure the corresponding credentials in the CI/CD environment.
+
+
+## GitHub Actions
+
+### Store Secrets
+
+Before creating the workflow, add the following secrets to the repository (under "Settings" > "Secrets and variables" > "Actions"):
+
+* `PROWLER_CLOUD_API_KEY` — the Prowler Cloud API key
+* Cloud provider credentials (e.g., `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, or configure OIDC-based role assumption)
+
+### Workflow: Scheduled AWS Scan
+
+This workflow runs Prowler against an AWS account on a daily schedule and on every push to the `main` branch:
+
+```yaml
+name: Prowler Security Scan
+
+on:
+ schedule:
+ - cron: "0 3 * * *" # Daily at 03:00 UTC
+ push:
+ branches: [main]
+ workflow_dispatch: # Allow manual triggers
+
+permissions:
+ id-token: write # Required for OIDC
+ contents: read
+
+jobs:
+ prowler-scan:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ role-to-assume: arn:aws:iam::123456789012:role/ProwlerScanRole
+ aws-region: us-east-1
+
+ - name: Install Prowler
+ run: pip install prowler
+
+ - name: Run Prowler Scan
+ env:
+ PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }}
+ run: |
+ prowler aws --push-to-cloud
+```
+
+
+Replace `123456789012` with the actual AWS account ID and `ProwlerScanRole` with the IAM role name. For IAM role setup, refer to the [AWS authentication guide](/user-guide/providers/aws/authentication).
+
+
+### Workflow: Scan Specific Services on Pull Request
+
+To run targeted scans on pull requests without blocking the merge pipeline, use `continue-on-error`:
+
+```yaml
+name: Prowler PR Check
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ prowler-scan:
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ role-to-assume: arn:aws:iam::123456789012:role/ProwlerScanRole
+ aws-region: us-east-1
+
+ - name: Install Prowler
+ run: pip install prowler
+
+ - name: Run Prowler Scan
+ env:
+ PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }}
+ run: |
+ prowler aws --services s3,iam,ec2 --push-to-cloud
+```
+
+
+Limiting the scan to specific services with `--services` reduces execution time, making it practical for pull request checks.
+
+
+## GitLab CI
+
+### Store Variables
+
+Add the following CI/CD variables in the GitLab project (under "Settings" > "CI/CD" > "Variables"):
+
+* `PROWLER_CLOUD_API_KEY` — mark as **masked** and **protected**
+* Cloud provider credentials as needed
+
+### Pipeline: Scheduled AWS Scan
+
+Add the following to `.gitlab-ci.yml`:
+
+```yaml
+prowler-scan:
+ image: python:3.12-slim
+ stage: test
+ script:
+ - pip install prowler
+ - prowler aws --push-to-cloud
+ variables:
+ PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY
+ AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
+ AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
+ AWS_DEFAULT_REGION: "us-east-1"
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "schedule"
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+```
+
+To run the scan on a schedule, create a **Pipeline Schedule** in GitLab (under "Build" > "Pipeline Schedules") with the desired cron expression.
+
+### Pipeline: Multi-Provider Scan
+
+To scan multiple cloud providers in parallel:
+
+```yaml
+stages:
+ - security
+
+.prowler-base:
+ image: python:3.12-slim
+ stage: security
+ before_script:
+ - pip install prowler
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "schedule"
+
+prowler-aws:
+ extends: .prowler-base
+ script:
+ - prowler aws --push-to-cloud
+ variables:
+ PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY
+ AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
+ AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
+
+prowler-gcp:
+ extends: .prowler-base
+ script:
+ - prowler gcp --push-to-cloud
+ variables:
+ PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY
+ GOOGLE_APPLICATION_CREDENTIALS: $GCP_SERVICE_ACCOUNT_KEY
+```
+
+## Tips and Best Practices
+
+### When to Run Scans
+
+* **Scheduled scans** (daily or weekly) provide continuous monitoring and are ideal for baseline security assessments
+* **On-merge scans** catch configuration changes introduced by new code
+* **Pull request scans** provide early feedback but should target specific services to keep execution times reasonable
+
+### Handling Scan Failures
+
+By default, Prowler exits with a non-zero code when it finds failing checks. This causes the CI/CD job to fail. To prevent scan results from blocking the pipeline:
+
+* **GitHub Actions**: Add `continue-on-error: true` to the job
+* **GitLab CI**: Add `allow_failure: true` to the job
+
+
+Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for details.
+
+
+### Caching Prowler Installation
+
+For faster pipeline runs, cache the Prowler installation:
+
+**GitHub Actions:**
+```yaml
+- name: Cache pip packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-prowler
+ restore-keys: ${{ runner.os }}-pip-
+
+- name: Install Prowler
+ run: pip install prowler
+```
+
+**GitLab CI:**
+```yaml
+prowler-scan:
+ cache:
+ paths:
+ - .cache/pip
+ variables:
+ PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
+```
+
+### Output Formats
+
+To generate additional report formats alongside the cloud upload:
+
+```bash
+prowler aws --push-to-cloud -M csv,html -o /tmp/prowler-reports
+```
+
+This produces CSV and HTML files locally while also pushing OCSF findings to Prowler Cloud. The local files can be stored as CI/CD artifacts for archival purposes.
+
+### Scanning Multiple AWS Accounts
+
+To scan multiple accounts sequentially in a single job, use [role assumption](/user-guide/providers/aws/role-assumption):
+
+```bash
+prowler aws -R arn:aws:iam::111111111111:role/ProwlerScanRole --push-to-cloud
+prowler aws -R arn:aws:iam::222222222222:role/ProwlerScanRole --push-to-cloud
+```
+
+Each scan run creates a separate ingestion job in Prowler Cloud.
diff --git a/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx
new file mode 100644
index 0000000000..765bcf9313
--- /dev/null
+++ b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx
@@ -0,0 +1,207 @@
+---
+title: 'Run Kubernetes In-Cluster and Send Findings to Prowler Cloud'
+---
+
+This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads.
+
+## Prerequisites
+
+* A **Prowler Cloud** account with an active subscription (see [Prowler Cloud Pricing](https://prowler.com/pricing))
+* A Prowler Cloud **API key** with the **Manage Ingestions** permission (see [API Keys](/user-guide/tutorials/prowler-app-api-keys))
+* Access to a Kubernetes cluster with `kubectl` configured
+* Permissions to create ServiceAccounts, Roles, RoleBindings, Secrets, and CronJobs in the cluster
+
+## Step 1: Create the ServiceAccount and RBAC Resources
+
+Prowler needs a ServiceAccount with read access to cluster resources. Apply the manifests from the [`kubernetes` directory](https://github.com/prowler-cloud/prowler/tree/master/kubernetes) of the Prowler repository:
+
+```console
+kubectl apply -f kubernetes/prowler-sa.yaml
+kubectl apply -f kubernetes/prowler-role.yaml
+kubectl apply -f kubernetes/prowler-rolebinding.yaml
+```
+
+This creates:
+
+* A `prowler-sa` ServiceAccount in the `prowler-ns` namespace
+* A ClusterRole with the read permissions Prowler requires
+* A ClusterRoleBinding linking the ServiceAccount to the role
+
+For more details on these resources, refer to [Getting Started with Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s).
+
+## Step 2: Store the Prowler Cloud API Key as a Secret
+
+Create a Kubernetes Secret to hold the API key securely:
+
+```console
+kubectl create secret generic prowler-cloud-api-key \
+ --from-literal=api-key=pk_your_api_key_here \
+ --namespace prowler-ns
+```
+
+Replace `pk_your_api_key_here` with the actual API key from Prowler Cloud.
+
+
+Avoid embedding the API key directly in the CronJob manifest. Using a Kubernetes Secret keeps credentials out of version control and pod specs.
+
+
+## Step 3: Create the CronJob Manifest
+
+The CronJob runs Prowler on a schedule, scanning the cluster and pushing findings to Prowler Cloud with the `--push-to-cloud` flag.
+
+Create a file named `prowler-cronjob.yaml`:
+
+```yaml
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: prowler-k8s-scan
+ namespace: prowler-ns
+spec:
+ schedule: "0 2 * * *" # Runs daily at 02:00 UTC
+ concurrencyPolicy: Forbid
+ jobTemplate:
+ spec:
+ backoffLimit: 1
+ template:
+ metadata:
+ labels:
+ app: prowler
+ spec:
+ serviceAccountName: prowler-sa
+ containers:
+ - name: prowler
+ image: prowlercloud/prowler:stable
+ args:
+ - "kubernetes"
+ - "--push-to-cloud"
+ env:
+ - name: PROWLER_CLOUD_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: prowler-cloud-api-key
+ key: api-key
+ - name: CLUSTER_NAME
+ value: "my-cluster"
+ imagePullPolicy: Always
+ volumeMounts:
+ - name: var-lib-cni
+ mountPath: /var/lib/cni
+ readOnly: true
+ - name: var-lib-etcd
+ mountPath: /var/lib/etcd
+ readOnly: true
+ - name: var-lib-kubelet
+ mountPath: /var/lib/kubelet
+ readOnly: true
+ - name: etc-kubernetes
+ mountPath: /etc/kubernetes
+ readOnly: true
+ hostPID: true
+ restartPolicy: Never
+ volumes:
+ - name: var-lib-cni
+ hostPath:
+ path: /var/lib/cni
+ - name: var-lib-etcd
+ hostPath:
+ path: /var/lib/etcd
+ - name: var-lib-kubelet
+ hostPath:
+ path: /var/lib/kubelet
+ - name: etc-kubernetes
+ hostPath:
+ path: /etc/kubernetes
+```
+
+
+Replace `my-cluster` with a meaningful name for the cluster. This value appears in Prowler Cloud reports and helps identify the source of findings. See the `--cluster-name` flag documentation in [Getting Started with Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) for more details.
+
+
+### Customizing the Schedule
+
+The `schedule` field uses standard cron syntax. Common examples:
+
+* `"0 2 * * *"` — daily at 02:00 UTC
+* `"0 */6 * * *"` — every 6 hours
+* `"0 2 * * 1"` — weekly on Mondays at 02:00 UTC
+
+### Scanning Specific Namespaces
+
+To limit the scan to specific namespaces, add the `--namespace` flag to the `args` array:
+
+```yaml
+args:
+ - "kubernetes"
+ - "--push-to-cloud"
+ - "--namespace"
+ - "production,staging"
+```
+
+## Step 4: Deploy and Verify
+
+Apply the CronJob to the cluster:
+
+```console
+kubectl apply -f prowler-cronjob.yaml
+```
+
+To trigger an immediate test run without waiting for the schedule:
+
+```console
+kubectl create job prowler-test-run --from=cronjob/prowler-k8s-scan -n prowler-ns
+```
+
+Monitor the job execution:
+
+```console
+kubectl get pods -n prowler-ns -l app=prowler --watch
+```
+
+Check the logs to confirm findings were pushed successfully:
+
+```console
+kubectl logs -n prowler-ns -l app=prowler --tail=50
+```
+
+A successful upload produces output similar to:
+
+```
+Pushing findings to Prowler Cloud, please wait...
+
+Findings successfully pushed to Prowler Cloud. Ingestion job: fa8bc8c5-4925-46a0-9fe0-f6575905e094
+See more details here: https://cloud.prowler.com/scans
+```
+
+## Step 5: View Findings in Prowler Cloud
+
+Once the job completes and findings are pushed:
+
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/)
+2. Open the "Scans" section to verify the ingestion job status
+3. Browse findings under the Kubernetes provider
+
+For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation.
+
+## Tips and Troubleshooting
+
+* **Resource limits**: For large clusters, consider setting `resources.requests` and `resources.limits` on the container to prevent the scan from consuming excessive cluster resources.
+* **Network policies**: Ensure the Prowler pod can reach `api.prowler.com` over HTTPS (port 443). Adjust NetworkPolicies or egress rules if needed.
+* **Job history**: Kubernetes retains completed and failed jobs by default. Set `successfulJobsHistoryLimit` and `failedJobsHistoryLimit` in the CronJob spec to control cleanup:
+
+ ```yaml
+ spec:
+ successfulJobsHistoryLimit: 3
+ failedJobsHistoryLimit: 1
+ ```
+
+* **API key rotation**: When rotating the API key, update the Secret and restart any running jobs:
+
+ ```console
+ kubectl delete secret prowler-cloud-api-key -n prowler-ns
+ kubectl create secret generic prowler-cloud-api-key \
+ --from-literal=api-key=pk_new_api_key_here \
+ --namespace prowler-ns
+ ```
+
+* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for common error messages.
diff --git a/docs/user-guide/providers/googleworkspace/authentication.mdx b/docs/user-guide/providers/googleworkspace/authentication.mdx
index 84ef5c6086..d8812fa7b3 100644
--- a/docs/user-guide/providers/googleworkspace/authentication.mdx
+++ b/docs/user-guide/providers/googleworkspace/authentication.mdx
@@ -2,9 +2,13 @@
title: 'Google Workspace Authentication in Prowler'
---
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
Prowler for Google Workspace uses a **Service Account with Domain-Wide Delegation** to authenticate to the Google Workspace Admin SDK. This allows Prowler to read directory data on behalf of a super administrator without requiring an interactive login.
-## Required OAuth Scopes
+## Required Open Authorization (OAuth) Scopes
Prowler requests the following read-only OAuth 2.0 scopes from the Google Workspace Admin SDK:
@@ -20,16 +24,16 @@ The delegated user must be a **super administrator** in your Google Workspace or
## Setup Steps
-### Step 1: Create a GCP Project (if needed)
+### Step 1: Create a Google Cloud Platform (GCP) Project (if Needed)
-If you don't have a GCP project, create one at [https://console.cloud.google.com](https://console.cloud.google.com).
+If no GCP project exists, create one at [https://console.cloud.google.com](https://console.cloud.google.com).
The project is only used to host the Service Account — it does not need to have any Google Workspace data in it.
### Step 2: Enable the Admin SDK API
-1. Go to the [Google Cloud Console](https://console.cloud.google.com)
-2. Select your project
+1. Navigate to the [Google Cloud Console](https://console.cloud.google.com)
+2. Select the target project
3. Navigate to **APIs & Services → Library**
4. Search for **Admin SDK API**
5. Click **Enable**
@@ -48,8 +52,8 @@ The Service Account does not need any GCP IAM roles. Its access to Google Worksp
### Step 4: Generate a JSON Key
-1. Click on the Service Account you just created
-2. Go to the **Keys** tab
+1. Click the newly created Service Account
+2. Navigate to the **Keys** tab
3. Click **Add Key → Create new key**
4. Select **JSON** format
5. Click **Create** — the key file will download automatically
@@ -61,7 +65,7 @@ This JSON key grants access to your Google Workspace organization. Never commit
### Step 5: Configure Domain-Wide Delegation in Google Workspace
-1. Go to the [Google Workspace Admin Console](https://admin.google.com)
+1. Navigate to the [Google Workspace Admin Console](https://admin.google.com)
2. Navigate to **Security → Access and data control → API controls**
3. Click **Manage Domain Wide Delegation**
4. Click **Add new**
@@ -78,23 +82,26 @@ https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.google
Domain-Wide Delegation must be configured by a Google Workspace **super administrator**. It may take a few minutes to propagate after saving.
-### Step 6: Store Credentials Securely
+### Step 6: Provide Credentials to Prowler
-Set your credentials as environment variables:
+- **Prowler Cloud:** Paste the Service Account JSON content and enter the delegated user email in the credentials form when configuring the Google Workspace provider.
+- **Prowler CLI:** Export the credentials as environment variables:
-```bash
+```console
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/googleworkspace-sa.json"
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
+prowler googleworkspace
```
-Alternatively, if you need to pass credentials as a string (e.g., in CI/CD pipelines):
+Alternatively, to pass credentials as a string (e.g., in CI/CD pipelines):
-```bash
+```console
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT=$(cat /path/to/googleworkspace-sa.json)
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
+prowler googleworkspace
```
-## Credential Lookup Order
+## How Prowler Resolves Credentials
Prowler resolves credentials in the following order:
@@ -147,7 +154,7 @@ The Service Account cannot impersonate the delegated user. This usually means Do
- All three required OAuth scopes are included
- The delegated user is a super administrator
-### Permission Denied on Admin SDK calls
+### Permission Denied on Admin SDK Calls
If Prowler connects but returns empty results or permission errors for specific API calls:
diff --git a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx
index 6f86bde4a8..361de533e1 100644
--- a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx
+++ b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx
@@ -1,100 +1,131 @@
---
-title: 'Getting Started with Google Workspace'
+title: 'Getting Started With Google Workspace on Prowler'
---
-import { VersionBadge } from "/snippets/version-badge.mdx";
+import { VersionBadge } from "/snippets/version-badge.mdx"
-
-
-Prowler for Google Workspace allows you to audit your organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more.
+Prowler for Google Workspace audits the organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more.
## Prerequisites
-Before running Prowler with the Google Workspace provider, ensure you have:
+Set up authentication for Google Workspace with the [Google Workspace Authentication](/user-guide/providers/googleworkspace/authentication) guide before starting either path:
-1. A Google Workspace account with super administrator privileges
-2. A Google Cloud Platform (GCP) project to host the Service Account
-3. Authentication configured (see [Authentication](/user-guide/providers/googleworkspace/authentication)):
- - A **Service Account JSON key** from a GCP project with Domain-Wide Delegation enabled
+- **Service Account:** Create a Service Account in a GCP project with Domain-Wide Delegation enabled.
+- **OAuth Scopes:** Authorize the required read-only OAuth scopes in the Google Workspace Admin Console.
+- **Customer ID:** Identify the Google Workspace Customer ID to use as the provider identifier.
+- **Delegated User:** Have the email of a super administrator to use as the delegated user.
-## Quick Start
+
+
+ Onboard Google Workspace using Prowler Cloud
+
+
+ Onboard Google Workspace using Prowler CLI
+
+
+
+## Prowler Cloud
+
+
+
+### Step 1: Locate the Customer ID
+
+1. Log into the [Google Workspace Admin Console](https://admin.google.com).
+2. Navigate to "Account" > "Account Settings".
+3. Find the **Customer ID** on the Account Settings page.
+
+ 
+
+
+The Customer ID starts with the letter "C" followed by alphanumeric characters (e.g., `C0xxxxxxx`). This value acts as the unique identifier for the Google Workspace account in Prowler Cloud.
+
+
+### Step 2: Open Prowler Cloud
+
+1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
+2. Navigate to "Configuration" > "Cloud Providers".
+
+ 
+
+3. Click "Add Cloud Provider".
+
+ 
+
+4. Select "Google Workspace".
+
+ 
+
+### Step 3: Provide Credentials
+
+1. Enter the **Customer ID** and an optional alias, then click "Next".
+
+ 
+
+2. Paste the **Service Account JSON** credentials content.
+3. Enter the "Delegated User Email" (a super administrator in the Google Workspace organization).
+
+ 
+
+
+The Service Account JSON is the full content of the key file downloaded when creating the Service Account. Paste the entire JSON object, not just the file path. For setup instructions, see the [Authentication guide](/user-guide/providers/googleworkspace/authentication).
+
+
+### Step 4: Check Connection
+
+1. Click "Check Connection" to verify that the credentials and Domain-Wide Delegation are configured correctly.
+2. Prowler will test the Service Account impersonation and Admin SDK access.
+
+ 
+
+
+If the connection test fails, verify that Domain-Wide Delegation is properly configured and that all three OAuth scopes are authorized. It may take a few minutes for delegation changes to propagate. See the [Troubleshooting](/user-guide/providers/googleworkspace/authentication#troubleshooting) section for common errors.
+
+
+### Step 5: Launch the Scan
+
+1. Review the summary.
+2. Click "Launch Scan" to start auditing Google Workspace.
+
+ 
+
+---
+
+## Prowler CLI
+
+
### Step 1: Set Up Authentication
-Set your Service Account credentials file path and delegated user email as environment variables:
+Set your Service Account credentials and delegated user email following the [Google Workspace Authentication](/user-guide/providers/googleworkspace/authentication) guide:
-```bash
+```console
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json"
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
```
-### Step 2: Run Prowler
-
-```bash
-prowler googleworkspace
-```
-
-Prowler will authenticate as the delegated user and run all available security checks against your Google Workspace organization.
-
-## Authentication
-
-Prowler uses a **Service Account with Domain-Wide Delegation** to authenticate to Google Workspace. This requires:
-
-- A Service Account created in a GCP project
-- The Admin SDK API enabled in that project
-- Domain-Wide Delegation configured in the Google Workspace Admin Console
-- A super admin user email to impersonate
-
-### Using Environment Variables (Recommended)
-
-```bash
-export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json"
-export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
-prowler googleworkspace
-```
-
Alternatively, pass the credentials content directly as a JSON string:
-```bash
+```console
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT='{"type": "service_account", ...}'
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
+```
+
+### Step 2: Run the First Scan
+
+Run a baseline scan after credentials are configured:
+
+```console
prowler googleworkspace
```
-
-The delegated user must be a super admin email in your Google Workspace organization. The service account credentials must be provided via environment variables (`GOOGLEWORKSPACE_CREDENTIALS_FILE` or `GOOGLEWORKSPACE_CREDENTIALS_CONTENT`).
-
+Prowler authenticates as the delegated user and runs all available security checks against the Google Workspace organization.
-## Understanding the Output
-
-When Prowler runs successfully, it will display the credentials being used:
-
-```
-Using the Google Workspace credentials below:
-┌─────────────────────────────────────────────────────────┐
-│ Google Workspace Domain: yourdomain.com │
-│ Customer ID: C0xxxxxxx │
-│ Delegated User: admin@yourdomain.com │
-│ Authentication Method: Service Account with Domain-Wide │
-│ Delegation │
-└─────────────────────────────────────────────────────────┘
-```
-
-Findings are reported per check. For example, the `directory_super_admin_count` check verifies the number of super administrators is within a recommended range (2–4):
-
-- **PASS** — 2 to 4 super administrators found
-- **FAIL** — 0 or 1 (single point of failure) or 5+ (excessive privilege exposure)
-
-Output files are saved in the configured output directory (default: `output/`) in CSV, JSON-OCSF, and HTML formats.
-
-## Configuration
+### Step 3: Use a Custom Configuration (Optional)
Prowler uses a configuration file to customize provider behavior. To use a custom configuration:
-```bash
+```console
prowler googleworkspace --config-file /path/to/config.yaml
```
-## Next Steps
-
-- [Authentication](/user-guide/providers/googleworkspace/authentication) — Detailed guide on setting up a Service Account and Domain-Wide Delegation
+---
diff --git a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx
index 0ec8215776..c4f4822792 100644
--- a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx
+++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx
@@ -164,3 +164,7 @@ env:
```
+
+
+To set up a production-ready CronJob that runs Prowler on a schedule and sends findings to Prowler Cloud, see the [Run Kubernetes In-Cluster and Send Findings to Prowler Cloud](/user-guide/cookbooks/kubernetes-in-cluster) cookbook.
+
diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx
index 88d9a3a25d..646ee557de 100644
--- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx
+++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx
@@ -202,15 +202,135 @@ To expand the graph for detailed exploration, click the fullscreen icon in the g
width="700"
/>
-## Using Attack Paths with the MCP Server
+## Using Attack Paths with the MCP Server and Lighthouse AI
Attack Paths capabilities are also available through the [Prowler MCP Server](/getting-started/products/prowler-mcp), enabling interaction with Attack Paths data via AI assistants like Claude Desktop, Cursor, and other MCP clients.
+[Prowler Lighthouse AI](/getting-started/products/prowler-lighthouse-ai) also supports Attack Paths queries, allowing you to analyze privilege escalation chains and security misconfigurations directly from the chat interface.
+
The following MCP tools are available for Attack Paths:
- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans
- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan
- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships
+- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries
+
+### Example Questions
+
+Ask through the MCP Server or Lighthouse AI:
+
+- "Find EC2 instances exposed to the internet with access to sensitive S3 buckets"
+- "Are there any IAM roles that can escalate their own privileges?"
+- "Show me all internet-facing resources with open security groups"
+- "Which principals can create Lambda functions with privileged roles?"
+- "List all RDS instances with storage encryption disabled"
+- "Find S3 buckets that allow anonymous access"
+- "Are there any CloudFormation stacks that could be hijacked for privilege escalation?"
+- "Show me all roles that can be assumed for lateral movement"
+
+### Supported Queries
+
+Attack Paths currently supports the following built-in queries for AWS:
+
+#### Custom Attack Path Queries
+
+| Query | Description |
+|---|---|
+| **Internet-Exposed EC2 with Sensitive S3 Access** | Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets |
+
+#### Basic Resource Queries
+
+| Query | Description |
+|---|---|
+| **RDS Instances Inventory** | List all provisioned RDS database instances in the account |
+| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled |
+| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access |
+| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) |
+| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy |
+| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action |
+
+#### Network Exposure Queries
+
+| Query | Description |
+|---|---|
+| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet |
+| **Open Security Groups on Internet-Facing Resources** | Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0 |
+| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners |
+| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners |
+| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address |
+
+#### Privilege Escalation Queries
+
+These queries are based on research from [pathfinding.cloud](https://pathfinding.cloud) by Datadog.
+
+| Query | Description |
+|---|---|
+| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions |
+| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role |
+| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached |
+| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials |
+| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources |
+| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role |
+| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts |
+| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role |
+| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role |
+| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec |
+| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
+| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
+| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec |
+| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure |
+| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS |
+| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions |
+| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS |
+| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed |
+| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS |
+| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code |
+| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code |
+| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster |
+| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster |
+| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code |
+| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials |
+| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions |
+| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials |
+| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions |
+| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code |
+| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions |
+| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution |
+| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges |
+| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account |
+| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions |
+| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions |
+| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges |
+| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions |
+| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges |
+| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges |
+| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges |
+| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members |
+| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members |
+| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions |
+| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group |
+| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges |
+| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
+| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access |
+| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges |
+| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
+| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
+| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access |
+| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
+| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions |
+| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions |
+| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions |
+| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions |
+| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role |
+| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions |
+| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment |
+| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions |
+| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions |
+| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions |
+| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup |
+| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS |
+| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS |
+| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role |
These tools enable workflows such as:
- Asking an AI assistant to identify privilege escalation paths in a specific AWS account
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 2d0333bf1a..9c9061a160 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -2,11 +2,13 @@
All notable changes to the **Prowler SDK** are documented in this file.
-## [5.21.2] (Prowler UNRELEASED)
+## [5.22.0] (Prowler v5.22.0)
### 🐞 Fixed
-- 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 [(#10373)](https://github.com/prowler-cloud/prowler/pull/10373)
+- 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)
- Oracle Cloud `events_rule_idp_group_mapping_changes` now recognizes the CIS 3.1 `add/remove` event names to avoid false positives [(#10411)](https://github.com/prowler-cloud/prowler/issues/10411)
---
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/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py
index 03c94bcfed..5071da4b20 100644
--- a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py
+++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py
@@ -21,9 +21,9 @@ class mysql_flexible_server_audit_log_connection_activated(Check):
"audit_log_events"
].resource_id
- if "CONNECTION" in server.configurations[
+ if "connection" in server.configurations[
"audit_log_events"
- ].value.split(","):
+ ].value.lower().split(","):
report.status = "PASS"
report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name}."
diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py
index c8ae94fb31..81918f7756 100644
--- a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py
+++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py
@@ -21,7 +21,7 @@ class mysql_flexible_server_audit_log_enabled(Check):
"audit_log_enabled"
].resource_id
- if server.configurations["audit_log_enabled"].value == "ON":
+ if server.configurations["audit_log_enabled"].value.lower() == "on":
report.status = "PASS"
report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name}."
diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py
index a18a1aba5e..79930de947 100644
--- a/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py
+++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py
@@ -20,7 +20,10 @@ class mysql_flexible_server_ssl_connection_enabled(Check):
report.resource_id = server.configurations[
"require_secure_transport"
].resource_id
- if server.configurations["require_secure_transport"].value == "ON":
+ if (
+ server.configurations["require_secure_transport"].value.lower()
+ == "on"
+ ):
report.status = "PASS"
report.status_extended = f"SSL connection is enabled for server {server.name} in subscription {subscription_name}."
diff --git a/skills/prowler-pr/SKILL.md b/skills/prowler-pr/SKILL.md
index c2d87716ad..e147d37fbe 100644
--- a/skills/prowler-pr/SKILL.md
+++ b/skills/prowler-pr/SKILL.md
@@ -132,6 +132,18 @@ Follow conventional commits:
4. ✅ Branch is up to date with main
5. ✅ Commits are clean and descriptive
+## Before Re-Requesting Review (REQUIRED)
+
+Resolve or respond to **every** open inline review thread before re-requesting review:
+
+1. **Agreed + fixed**: Commit the change. Reply with the commit hash so the reviewer can verify quickly:
+ > Fixed in `abc1234`.
+2. **Agreed but deferred**: Explain why it's out of scope for this PR and where it's tracked.
+3. **Disagreed**: Reply with clear technical reasoning. Do not leave threads silently open.
+4. **Re-request review** only after all threads are in a clean state — either resolved or explicitly responded to.
+
+> **Rule of thumb**: A reviewer should never have to wonder "did they see my comment?" when they re-open the PR.
+
## Resources
- **Documentation**: See [references/](references/) for links to local developer guide
diff --git a/skills/prowler-ui/SKILL.md b/skills/prowler-ui/SKILL.md
index b1e68f8170..3e6407889e 100644
--- a/skills/prowler-ui/SKILL.md
+++ b/skills/prowler-ui/SKILL.md
@@ -186,6 +186,109 @@ cd ui && pnpm run build
cd ui && pnpm start
```
+## Batch vs Instant Component API (REQUIRED)
+
+When a component supports both **batch** (deferred, submit-based) and **instant** (immediate callback) behavior, model the coupling with a discriminated union — never as independent optionals. Coupled props must be all-or-nothing.
+
+```typescript
+// ❌ NEVER: Independent optionals — allows invalid half-states
+interface FilterProps {
+ onBatchApply?: (values: string[]) => void;
+ onInstantChange?: (value: string) => void;
+ isBatchMode?: boolean;
+}
+
+// ✅ ALWAYS: Discriminated union — one valid shape per mode
+type BatchProps = {
+ mode: "batch";
+ onApply: (values: string[]) => void;
+ onCancel: () => void;
+};
+
+type InstantProps = {
+ mode: "instant";
+ onChange: (value: string) => void;
+ // onApply/onCancel are forbidden here via structural exclusion
+ onApply?: never;
+ onCancel?: never;
+};
+
+type FilterProps = BatchProps | InstantProps;
+```
+
+This makes invalid prop combinations a compile error, not a runtime surprise.
+
+## Reuse Shared Display Utilities First (REQUIRED)
+
+Before adding **local** display maps (labels, provider names, status strings, category formatters), search `ui/types/*` and `ui/lib/*` for existing helpers.
+
+```typescript
+// ✅ CHECK THESE FIRST before creating a new map:
+// ui/lib/utils.ts → general formatters
+// ui/types/providers.ts → provider display names, icons
+// ui/types/findings.ts → severity/status display maps
+// ui/types/compliance.ts → category/group formatters
+
+// ❌ NEVER add a local map that already exists:
+const SEVERITY_LABELS: Record = {
+ critical: "Critical",
+ high: "High",
+ // ...duplicating an existing shared map
+};
+
+// ✅ Import and reuse instead:
+import { severityLabel } from "@/types/findings";
+```
+
+If a helper doesn't exist and will be used in 2+ places, add it to `ui/lib/` or `ui/types/` and reuse it. Keep local only if used in exactly one place.
+
+## Derived State Rule (REQUIRED)
+
+Avoid `useState` + `useEffect` patterns that mirror props or searchParams — they create sync bugs and unnecessary re-renders. Derive values directly from the source of truth.
+
+```typescript
+// ❌ NEVER: Mirror props into state via effect
+const [localFilter, setLocalFilter] = useState(filter);
+useEffect(() => { setLocalFilter(filter); }, [filter]);
+
+// ✅ ALWAYS: Derive directly
+const localFilter = filter; // or compute inline
+```
+
+If local state is genuinely needed (e.g., optimistic UI, pending edits before submit), add a short comment:
+
+```typescript
+// Local state needed: user edits are buffered until "Apply" is clicked
+const [pending, setPending] = useState(initialValues);
+```
+
+## Strict Key Typing for Label Maps (REQUIRED)
+
+Avoid `Record` when the key set is known. Use an explicit union type or a const-key object so typos are caught at compile time.
+
+```typescript
+// ❌ Loose — typos compile silently
+const STATUS_LABELS: Record = {
+ actve: "Active", // typo, no error
+};
+
+// ✅ Tight — union key
+type Status = "active" | "inactive" | "pending";
+const STATUS_LABELS: Record = {
+ active: "Active",
+ inactive: "Inactive",
+ pending: "Pending",
+ // actve: "Active" ← compile error
+};
+
+// ✅ Also fine — const satisfies
+const STATUS_LABELS = {
+ active: "Active",
+ inactive: "Inactive",
+ pending: "Pending",
+} as const satisfies Record;
+```
+
## QA Checklist Before Commit
- [ ] `pnpm run typecheck` passes
@@ -199,6 +302,15 @@ cd ui && pnpm start
- [ ] Accessibility: keyboard navigation, ARIA labels
- [ ] Mobile responsive (if applicable)
+## Pre-Re-Review Checklist (Review Thread Hygiene)
+
+Before requesting re-review from a reviewer:
+
+- [ ] Every unresolved inline thread has been either fixed or explicitly answered with a rationale
+- [ ] If you agreed with a comment: the change is committed and the commit hash is mentioned in the reply
+- [ ] If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open
+- [ ] Re-request review only after all threads are in a clean state
+
## Migrations Reference
| From | To | Key Changes |
diff --git a/skills/typescript/SKILL.md b/skills/typescript/SKILL.md
index 046465a75b..4d664d3189 100644
--- a/skills/typescript/SKILL.md
+++ b/skills/typescript/SKILL.md
@@ -102,6 +102,38 @@ function isUser(value: unknown): value is User {
}
```
+## Coupled Optional Props (REQUIRED)
+
+Do not model semantically coupled props as independent optionals — this allows invalid half-states that compile but break at runtime. Use discriminated unions with `never` to make invalid combinations impossible.
+
+```typescript
+// ❌ BEFORE: Independent optionals — half-states allowed
+interface PaginationProps {
+ onPageChange?: (page: number) => void;
+ pageSize?: number;
+ currentPage?: number;
+}
+
+// ✅ AFTER: Discriminated union — shape is all-or-nothing
+type ControlledPagination = {
+ controlled: true;
+ currentPage: number;
+ pageSize: number;
+ onPageChange: (page: number) => void;
+};
+
+type UncontrolledPagination = {
+ controlled: false;
+ currentPage?: never;
+ pageSize?: never;
+ onPageChange?: never;
+};
+
+type PaginationProps = ControlledPagination | UncontrolledPagination;
+```
+
+**Key rule:** If two or more props are only meaningful together, they belong to the same discriminated union branch. Mixing them as independent optionals shifts correctness responsibility from the type system to runtime guards.
+
## Import Types
```typescript
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/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py
index 84daaa758c..47ef92551b 100644
--- a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py
+++ b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py
@@ -56,6 +56,57 @@ class Test_mysql_flexible_server_audit_log_connection_activated:
result = check.execute()
assert len(result) == 0
+ def test_mysql_audit_log_connection_activated_lowercase(self):
+ server_name = str(uuid4())
+ mysql_client = mock.MagicMock
+ mysql_client.flexible_servers = {
+ AZURE_SUBSCRIPTION_ID: {
+ "/subscriptions/resource_id": FlexibleServer(
+ resource_id="/subscriptions/resource_id",
+ name=server_name,
+ location="location",
+ version="version",
+ configurations={
+ "audit_log_events": Configuration(
+ resource_id=f"/subscriptions/{server_name}/configurations/audit_log_events",
+ description="description",
+ value="connection",
+ )
+ },
+ )
+ }
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_connection_activated.mysql_flexible_server_audit_log_connection_activated.mysql_client",
+ new=mysql_client,
+ ),
+ ):
+ from prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_connection_activated.mysql_flexible_server_audit_log_connection_activated import (
+ mysql_flexible_server_audit_log_connection_activated,
+ )
+
+ check = mysql_flexible_server_audit_log_connection_activated()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+ assert result[0].resource_name == server_name
+ assert result[0].location == "location"
+ assert (
+ result[0].resource_id
+ == f"/subscriptions/{server_name}/configurations/audit_log_events"
+ )
+ assert (
+ result[0].status_extended
+ == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}."
+ )
+
def test_mysql_audit_log_connection_not_connection(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py
index ad243c5807..7c32f337fd 100644
--- a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py
+++ b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py
@@ -56,6 +56,57 @@ class Test_mysql_flexible_server_audit_log_enabled:
result = check.execute()
assert len(result) == 0
+ def test_mysql_audit_log_enabled_lowercase(self):
+ server_name = str(uuid4())
+ mysql_client = mock.MagicMock
+ mysql_client.flexible_servers = {
+ AZURE_SUBSCRIPTION_ID: {
+ "/subscriptions/resource_id": FlexibleServer(
+ resource_id="/subscriptions/resource_id",
+ name=server_name,
+ location="location",
+ version="version",
+ configurations={
+ "audit_log_enabled": Configuration(
+ resource_id=f"/subscriptions/{server_name}/configurations/audit_log_enabled",
+ description="description",
+ value="on",
+ )
+ },
+ )
+ }
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_enabled.mysql_flexible_server_audit_log_enabled.mysql_client",
+ new=mysql_client,
+ ),
+ ):
+ from prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_enabled.mysql_flexible_server_audit_log_enabled import (
+ mysql_flexible_server_audit_log_enabled,
+ )
+
+ check = mysql_flexible_server_audit_log_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+ assert result[0].resource_name == server_name
+ assert result[0].location == "location"
+ assert (
+ result[0].resource_id
+ == f"/subscriptions/{server_name}/configurations/audit_log_enabled"
+ )
+ assert (
+ result[0].status_extended
+ == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}."
+ )
+
def test_mysql_audit_log_disabled(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py
index f540fe4865..2b87a28d8f 100644
--- a/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py
+++ b/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py
@@ -107,6 +107,57 @@ class Test_mysql_flexible_server_ssl_connection_enabled:
== f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}."
)
+ def test_mysql_connection_enabled_lowercase(self):
+ server_name = str(uuid4())
+ mysql_client = mock.MagicMock
+ mysql_client.flexible_servers = {
+ AZURE_SUBSCRIPTION_ID: {
+ "/subscriptions/resource_id": FlexibleServer(
+ resource_id="/subscriptions/resource_id",
+ name=server_name,
+ location="location",
+ version="version",
+ configurations={
+ "require_secure_transport": Configuration(
+ resource_id=f"/subscriptions/{server_name}/configurations/require_secure_transport",
+ description="description",
+ value="on",
+ )
+ },
+ )
+ }
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.mysql.mysql_flexible_server_ssl_connection_enabled.mysql_flexible_server_ssl_connection_enabled.mysql_client",
+ new=mysql_client,
+ ),
+ ):
+ from prowler.providers.azure.services.mysql.mysql_flexible_server_ssl_connection_enabled.mysql_flexible_server_ssl_connection_enabled import (
+ mysql_flexible_server_ssl_connection_enabled,
+ )
+
+ check = mysql_flexible_server_ssl_connection_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+ assert result[0].resource_name == server_name
+ assert result[0].location == "location"
+ assert (
+ result[0].resource_id
+ == f"/subscriptions/{server_name}/configurations/require_secure_transport"
+ )
+ assert (
+ result[0].status_extended
+ == f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}."
+ )
+
def test_mysql_ssl_connection_disabled(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 2c17bcc5d6..58394198ed 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -2,6 +2,18 @@
All notable changes to the **Prowler UI** are documented in this file.
+## [1.22.0] (Prowler v5.22.0)
+
+### 🚀 Added
+
+- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397)
+
+### 🔄 Changed
+
+- Findings filters now use a batch-apply pattern with an Apply Filters button, filter summary strip, and independent filter options instead of triggering API calls on every selection [(#10388)](https://github.com/prowler-cloud/prowler/pull/10388)
+
+---
+
## [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)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx
index 68ffec2706..362396f03f 100644
--- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx
+++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx
@@ -27,7 +27,11 @@ import {
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
-import type { ProviderProps, ProviderType } from "@/types/providers";
+import {
+ getProviderDisplayName,
+ type ProviderProps,
+ type ProviderType,
+} from "@/types/providers";
const PROVIDER_ICON: Record = {
aws: ,
@@ -46,60 +50,73 @@ const PROVIDER_ICON: Record = {
openstack: ,
};
-interface AccountsSelectorProps {
+/** Common props shared by both batch and instant modes. */
+interface AccountsSelectorBaseProps {
providers: ProviderProps[];
+ /**
+ * Currently selected provider types (from the pending ProviderTypeSelector state).
+ * Used only for contextual description/empty-state messaging — does NOT narrow
+ * the list of available accounts, which remains independent of provider selection.
+ */
+ selectedProviderTypes?: string[];
}
-export function AccountsSelector({ providers }: AccountsSelectorProps) {
+/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
+interface AccountsSelectorBatchProps extends AccountsSelectorBaseProps {
+ /**
+ * Called instead of navigating immediately.
+ * Use this on pages that batch filter changes (e.g. Findings).
+ *
+ * @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_id__in"
+ * @param values - The selected values array
+ */
+ onBatchChange: (filterKey: string, values: string[]) => void;
+ /**
+ * Pending selected values controlled by the parent.
+ * Reflects pending state before Apply is clicked.
+ */
+ selectedValues: string[];
+}
+
+/** Instant mode: URL-driven — neither callback nor controlled value. */
+interface AccountsSelectorInstantProps extends AccountsSelectorBaseProps {
+ onBatchChange?: never;
+ selectedValues?: never;
+}
+
+type AccountsSelectorProps =
+ | AccountsSelectorBatchProps
+ | AccountsSelectorInstantProps;
+
+export function AccountsSelector({
+ providers,
+ onBatchChange,
+ selectedValues,
+ selectedProviderTypes,
+}: AccountsSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const filterKey = "filter[provider_id__in]";
const current = searchParams.get(filterKey) || "";
- const selectedTypes = searchParams.get("filter[provider_type__in]") || "";
- const selectedTypesList = selectedTypes
- ? selectedTypes.split(",").filter(Boolean)
- : [];
- const selectedIds = current ? current.split(",").filter(Boolean) : [];
- const visibleProviders = providers
- // .filter((p) => p.attributes.connection?.connected)
- .filter((p) =>
- selectedTypesList.length > 0
- ? selectedTypesList.includes(p.attributes.provider)
- : true,
- );
+ const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
+
+ // In batch mode, use the parent-controlled pending values; otherwise, use URL state.
+ const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
+ const visibleProviders = providers;
+ // .filter((p) => p.attributes.connection?.connected)
const handleMultiValueChange = (ids: string[]) => {
+ if (onBatchChange) {
+ onBatchChange("provider_id__in", ids);
+ return;
+ }
navigateWithParams((params) => {
params.delete(filterKey);
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
}
-
- // Auto-deselect provider types that no longer have any selected accounts
- if (selectedTypesList.length > 0) {
- // Get provider types of currently selected accounts
- const selectedProviders = providers.filter((p) => ids.includes(p.id));
- const selectedProviderTypes = new Set(
- selectedProviders.map((p) => p.attributes.provider),
- );
-
- // Keep only provider types that still have selected accounts
- const remainingProviderTypes = selectedTypesList.filter((type) =>
- selectedProviderTypes.has(type as ProviderType),
- );
-
- // Update provider_type__in filter
- if (remainingProviderTypes.length > 0) {
- params.set(
- "filter[provider_type__in]",
- remainingProviderTypes.join(","),
- );
- } else {
- params.delete("filter[provider_type__in]");
- }
- }
});
};
@@ -115,9 +132,12 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
);
};
+ // Build a contextual description based on currently selected provider types.
+ // This is purely for user guidance (aria label + empty state) and does NOT
+ // narrow the list of available accounts — all providers remain selectable.
const filterDescription =
- selectedTypesList.length > 0
- ? `Showing accounts for ${selectedTypesList.join(", ")} providers`
+ selectedProviderTypes && selectedProviderTypes.length > 0
+ ? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "All connected cloud provider accounts";
return (
@@ -176,8 +196,8 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
>
) : (
- {selectedTypesList.length > 0
- ? "No accounts available for selected providers"
+ {selectedProviderTypes && selectedProviderTypes.length > 0
+ ? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "No connected accounts available"}
)}
diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx
index 6c62a34667..74d728fd74 100644
--- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx
+++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx
@@ -152,22 +152,60 @@ const PROVIDER_DATA: Record<
},
};
-type ProviderTypeSelectorProps = {
+/** Common props shared by both batch and instant modes. */
+interface ProviderTypeSelectorBaseProps {
providers: ProviderProps[];
-};
+}
+
+/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
+interface ProviderTypeSelectorBatchProps extends ProviderTypeSelectorBaseProps {
+ /**
+ * Called instead of navigating immediately.
+ * Use this on pages that batch filter changes (e.g. Findings).
+ *
+ * @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_type__in"
+ * @param values - The selected values array
+ */
+ onBatchChange: (filterKey: string, values: string[]) => void;
+ /**
+ * Pending selected values controlled by the parent.
+ * Reflects pending state before Apply is clicked.
+ */
+ selectedValues: string[];
+}
+
+/** Instant mode: URL-driven — neither callback nor controlled value. */
+interface ProviderTypeSelectorInstantProps
+ extends ProviderTypeSelectorBaseProps {
+ onBatchChange?: never;
+ selectedValues?: never;
+}
+
+type ProviderTypeSelectorProps =
+ | ProviderTypeSelectorBatchProps
+ | ProviderTypeSelectorInstantProps;
export const ProviderTypeSelector = ({
providers,
+ onBatchChange,
+ selectedValues,
}: ProviderTypeSelectorProps) => {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
- const selectedTypes = currentProviders
+ const urlSelectedTypes = currentProviders
? currentProviders.split(",").filter(Boolean)
: [];
+ // In batch mode, use the parent-controlled pending values; otherwise, use URL state.
+ const selectedTypes = onBatchChange ? selectedValues : urlSelectedTypes;
+
const handleMultiValueChange = (values: string[]) => {
+ if (onBatchChange) {
+ onBatchChange("provider_type__in", values);
+ return;
+ }
navigateWithParams((params) => {
// Update provider_type__in
if (values.length > 0) {
@@ -175,10 +213,6 @@ export const ProviderTypeSelector = ({
} else {
params.delete("filter[provider_type__in]");
}
-
- // Clear account selection when changing provider types
- // User should manually select accounts if they want to filter by specific accounts
- params.delete("filter[provider_id__in]");
});
};
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 (
+
+
+
+