diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 18fa188083..4876fef783 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,22 +2,27 @@
All notable changes to the **Prowler API** are documented in this file.
-## [1.23.0] (Prowler UNRELEASED)
+## [1.24.0] (Prowler UNRELEASED)
+
+### 🐞 Fixed
+
+- Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428)
+
+## [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)
-- Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428)
+- 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)
-
-## [1.22.2] (Prowler UNRELEASED)
-
-### 🔐 Security
-
- Bump `flask` to 3.1.3 (CVE-2026-27205) and `werkzeug` to 3.1.6 (CVE-2026-27199) [(#10430)](https://github.com/prowler-cloud/prowler/pull/10430)
---
@@ -35,7 +40,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/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/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/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/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 9582f7123a..883b49eb76 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -2,7 +2,7 @@
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
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/ui/CHANGELOG.md b/ui/CHANGELOG.md
index c20aa5bf99..58394198ed 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -2,12 +2,16 @@
All notable changes to the **Prowler UI** are documented in this file.
-## [1.22.0] (Prowler UNRELEASED)
+## [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)
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)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx
index 91b2bffc04..30a258e61b 100644
--- a/ui/app/(prowler)/findings/page.tsx
+++ b/ui/app/(prowler)/findings/page.tsx
@@ -24,10 +24,6 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
-import {
- createProviderDetailsMappingById,
- extractProviderIds,
-} from "@/lib/provider-helpers";
import { ScanEntity, ScanProps } from "@/types";
import { FindingProps, SearchParamsProps } from "@/types/components";
@@ -124,12 +120,6 @@ export default async function Findings({
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
- // Extract provider IDs and details using helper functions
- const providerIds = providersData ? extractProviderIds(providersData) : [];
- const providerDetails = providersData
- ? createProviderDetailsMappingById(providerIds, providersData)
- : [];
-
// Extract scan UUIDs with "completed" state and more than one resource
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -151,9 +141,6 @@ export default async function Findings({
({
+ Check: () => ,
+ X: () => ,
+}));
+
+// Mock @/components/shadcn to avoid next-auth import chain
+vi.mock("@/components/shadcn", () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ "aria-label": ariaLabel,
+ variant,
+ size,
+ }: {
+ children?: React.ReactNode;
+ disabled?: boolean;
+ onClick?: () => void;
+ "aria-label"?: string;
+ variant?: string;
+ size?: string;
+ }) => (
+
+ ),
+}));
+
+vi.mock("@/lib/utils", () => ({
+ cn: (...classes: (string | undefined | false)[]) =>
+ classes.filter(Boolean).join(" "),
+}));
+
+import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
+
+// ── Future E2E coverage ────────────────────────────────────────────────────
+// TODO (E2E): Full apply-filters button flow should be covered in Playwright tests:
+// - Button appears disabled when no filters have been staged
+// - Button shows correct count after staging multiple filters
+// - Clicking Apply pushes all pending filters to the URL in one navigation event
+// - Clicking Discard resets pending state to current URL state (staged filters disappear)
+// ──────────────────────────────────────────────────────────────────────────
+
+describe("ApplyFiltersButton", () => {
+ // ── No changes ───────────────────────────────────────────────────────────
+
+ describe("when hasChanges is false", () => {
+ it("should render the Apply Filters button as disabled", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ const applyButton = screen.getByRole("button", {
+ name: "Apply Filters",
+ });
+ expect(applyButton).toBeDisabled();
+ });
+
+ it("should NOT render the discard (X) button when there are no changes", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.queryByRole("button", {
+ name: /discard/i,
+ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should show 'Apply Filters' label without count", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.getByRole("button", { name: "Apply Filters" }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ── Has changes ──────────────────────────────────────────────────────────
+
+ describe("when hasChanges is true", () => {
+ it("should render the Apply Filters button as enabled", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ const applyButton = screen.getByRole("button", {
+ name: "Apply Filters (2)",
+ });
+ expect(applyButton).not.toBeDisabled();
+ });
+
+ it("should show the change count in the button label", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.getByRole("button", { name: "Apply Filters (3)" }),
+ ).toBeInTheDocument();
+ });
+
+ it("should show 'Apply Filters' (without count) when changeCount is 0 but hasChanges is true", () => {
+ // Given — hasChanges=true but changeCount=0 (edge case)
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.getByRole("button", { name: "Apply Filters" }),
+ ).toBeInTheDocument();
+ });
+
+ it("should render the discard (X) button", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.getByRole("button", { name: /discard pending filter changes/i }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ── onApply interaction ──────────────────────────────────────────────────
+
+ describe("onApply", () => {
+ it("should call onApply when the Apply Filters button is clicked", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onApply = vi.fn();
+ const onDiscard = vi.fn();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: "Apply Filters (1)" }),
+ );
+
+ // Then
+ expect(onApply).toHaveBeenCalledTimes(1);
+ expect(onDiscard).not.toHaveBeenCalled();
+ });
+
+ it("should NOT call onApply when the button is disabled", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onApply = vi.fn();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Apply Filters" }));
+
+ // Then — disabled button should not fire
+ expect(onApply).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── onDiscard interaction ────────────────────────────────────────────────
+
+ describe("onDiscard", () => {
+ it("should call onDiscard when the Discard button is clicked", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onApply = vi.fn();
+ const onDiscard = vi.fn();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: /discard pending filter changes/i }),
+ );
+
+ // Then
+ expect(onDiscard).toHaveBeenCalledTimes(1);
+ expect(onApply).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/ui/components/filters/apply-filters-button.tsx b/ui/components/filters/apply-filters-button.tsx
new file mode 100644
index 0000000000..be0fa183a7
--- /dev/null
+++ b/ui/components/filters/apply-filters-button.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { Check, X } from "lucide-react";
+
+import { Button } from "@/components/shadcn";
+import { cn } from "@/lib/utils";
+
+export interface ApplyFiltersButtonProps {
+ /** Whether there are pending changes that differ from the applied (URL) state */
+ hasChanges: boolean;
+ /** Number of filter keys that have pending changes */
+ changeCount: number;
+ /** Called when the user clicks "Apply Filters" */
+ onApply: () => void;
+ /** Called when the user clicks the discard (X) action */
+ onDiscard: () => void;
+ /** Optional extra class names for the outer wrapper */
+ className?: string;
+}
+
+/**
+ * Displays an "Apply Filters" button with an optional discard action.
+ *
+ * - Shows the count of pending changes when `hasChanges` is true.
+ * - The apply button is disabled (and visually muted) when there are no changes.
+ * - The discard (X) button only appears when there are pending changes.
+ * - Uses Prowler's shadcn `Button` component.
+ */
+export const ApplyFiltersButton = ({
+ hasChanges,
+ changeCount,
+ onApply,
+ onDiscard,
+ className,
+}: ApplyFiltersButtonProps) => {
+ const label =
+ changeCount > 0 ? `Apply Filters (${changeCount})` : "Apply Filters";
+
+ return (
+
+
+
+ {hasChanges && (
+
+ )}
+
+ );
+};
diff --git a/ui/components/filters/clear-filters-button.tsx b/ui/components/filters/clear-filters-button.tsx
index 40dbaf9216..0cc586cc70 100644
--- a/ui/components/filters/clear-filters-button.tsx
+++ b/ui/components/filters/clear-filters-button.tsx
@@ -17,6 +17,19 @@ export interface ClearFiltersButtonProps {
showCount?: boolean;
/** Use link style (text only, no button background) */
variant?: "link" | "default";
+ /**
+ * Optional callback for batch mode. When provided, this is called INSTEAD
+ * of pushing URL params directly. Useful for clearing pending filter state
+ * without immediately navigating.
+ */
+ onClear?: () => void;
+ /**
+ * In batch mode, the number of pending filter keys that have non-empty values.
+ * When provided alongside `onClear`, overrides the URL-based count shown by
+ * `showCount`. This ensures the displayed count reflects the pending state
+ * (not the last-applied URL state) while the user is editing filters.
+ */
+ pendingCount?: number;
}
export const ClearFiltersButton = ({
@@ -24,6 +37,8 @@ export const ClearFiltersButton = ({
ariaLabel = "Reset",
showCount = false,
variant = "link",
+ onClear,
+ pendingCount,
}: ClearFiltersButtonProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -51,17 +66,27 @@ export const ClearFiltersButton = ({
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}, [router, searchParams, pathname]);
- // Only show button if there are filters other than the excluded ones
- if (filterCount === 0) {
+ // In batch mode: use pendingCount if provided; otherwise fall back to URL count.
+ // In instant mode: always use URL count.
+ const displayCount =
+ onClear && pendingCount !== undefined ? pendingCount : filterCount;
+
+ // In instant mode: hide when no URL filters exist
+ if (!onClear && filterCount === 0) {
return null;
}
- const displayText = showCount ? `Clear Filters (${filterCount})` : text;
+ // In batch mode: hide when there are no pending or URL filters to clear
+ if (onClear && displayCount === 0) {
+ return null;
+ }
+
+ const displayText = showCount ? `Clear Filters (${displayCount})` : text;
return (