From e9bbde2f0141d25e4e7c925bae6c23dda008bc52 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:12:10 +0200 Subject: [PATCH] fix(api): remove cartesian product in Attack Paths IAM privesc queries (#12136) --- ...ttack-paths-iam-privesc-cartesian.fixed.md | 1 + .../backend/api/attack_paths/queries/aws.py | 243 ++++++++++++------ 2 files changed, 170 insertions(+), 74 deletions(-) create mode 100644 api/changelog.d/attack-paths-iam-privesc-cartesian.fixed.md diff --git a/api/changelog.d/attack-paths-iam-privesc-cartesian.fixed.md b/api/changelog.d/attack-paths-iam-privesc-cartesian.fixed.md new file mode 100644 index 0000000000..61d75fbd39 --- /dev/null +++ b/api/changelog.d/attack-paths-iam-privesc-cartesian.fixed.md @@ -0,0 +1 @@ +Attack Paths IAM privilege-escalation queries no longer build an all-nodes × all-resource-items cartesian product, fixing runtime errors and timeouts on accounts with many IAM roles, users, or groups diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index 60660a1632..6f44ce34f2 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -1927,12 +1927,20 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Find target users that the principal can create access keys for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate this statement's resource values into a list so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target users that the principal can create access keys for. + // Bind name/arn once so the `any` predicate reads locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -1975,16 +1983,24 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target users that the principal can rotate access keys for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate both statements' resource values into lists so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_user.name - OR target_user.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target users that the principal can rotate access keys for. + // Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2019,12 +2035,20 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Find target users that the principal can create login profiles for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate this statement's resource values into a list so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target users that the principal can create login profiles for. + // Bind name/arn once so the `any` predicate reads locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2097,12 +2121,20 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Find target users that the principal can update login profiles for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate this statement's resource values into a list so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target users that the principal can update login profiles for. + // Bind name/arn once so the `any` predicate reads locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2333,12 +2365,21 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( // Collapse the action-item fan-out: one row per (statement chain), not per matching action WITH DISTINCT aws, stmt, path_principal - // Find target roles whose trust policy this statement's resource can target - MATCH path_target = (aws)--(target_role:AWSRole) + // Pre-aggregate this statement's resource values into a list so the role + // match below is evaluated once per role (in-memory `any`) instead of + // building an (all-roles x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_role.name - OR target_role.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target roles whose trust policy this statement's resource can target. + // Bind the role's name/arn once so the `any` predicate reads them from a + // local variable instead of re-reading the property store per resource. + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2373,12 +2414,20 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( OR act.value = '*' WITH DISTINCT aws, principal, stmt, path_principal - // Find target groups the principal can add users to - MATCH path_target = (aws)--(target_group:AWSGroup) + // Pre-aggregate this statement's resource values into a list so the group + // match below is evaluated once per group (in-memory `any`) instead of + // building an (all-groups x all-resource-items) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_group.name - OR target_group.arn CONTAINS res.value + WITH aws, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Find target groups the principal can add users to. + // Bind name/arn once so the `any` predicate reads locals. + MATCH path_target = (aws)--(target_group:AWSGroup) + WITH path_principal, path_target, res_values, res_wildcard, + target_group.name AS gname, target_group.arn AS garn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS gname OR garn CONTAINS rv]) > 0 WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2462,16 +2511,24 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target users the principal can attach policies to and create keys for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate both statements' resource values into lists so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_user.name - OR target_user.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target users the principal can attach policies to and create keys for. + // Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2596,16 +2653,24 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target users the principal can put policies on and create keys for - MATCH path_target = (aws)--(target_user:AWSUser) + // Pre-aggregate both statements' resource values into lists so the user + // match below is evaluated once per user (in-memory `any`) instead of + // building an (all-users x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_user.name - OR target_user.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_user.name - OR target_user.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target users the principal can put policies on and create keys for. + // Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_user:AWSUser) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_user.name AS uname, target_user.arn AS uarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2647,16 +2712,24 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target roles the principal can attach policies to and update trust policy for - MATCH path_target = (aws)--(target_role:AWSRole) + // Pre-aggregate both statements' resource values into lists so the role + // match below is evaluated once per role (in-memory `any`) instead of + // building an (all-roles x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_role.name - OR target_role.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_role.name - OR target_role.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target roles the principal can attach policies to and update trust + // policy for. Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2698,17 +2771,31 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target roles with customer-managed policies the principal can modify and update trust policy for - MATCH path_target = (aws)--(target_role:AWSRole) + // Pre-aggregate both statements' resource values into lists so the role + // and policy matches below are evaluated with an in-memory `any` instead + // of building an (all-roles x res2) x (policies x res) cartesian product. MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_role.name - OR target_role.arn CONTAINS res2.value - MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy) - WHERE target_policy.arn CONTAINS $provider_uid + WITH aws, stmt, path_principal, collect(DISTINCT res2.value) AS res2_values MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR target_policy.arn CONTAINS res.value + WITH aws, path_principal, res2_values, collect(DISTINCT res.value) AS res_values + WITH aws, path_principal, res2_values, res_values, + ('*' IN res2_values) AS res2_wildcard, + ('*' IN res_values) AS res_wildcard + + // Find target roles with customer-managed policies the principal can + // modify and update trust policy for. Bind name/arn once so the `any` + // predicates read locals instead of re-reading the property store. + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, target_role, res_values, res_wildcard, + res2_values, res2_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res2_wildcard + OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy) + WITH path_principal, path_target, res_values, res_wildcard, + target_policy.arn AS parn + WHERE parn CONTAINS $provider_uid + AND (res_wildcard OR size([rv IN res_values WHERE parn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths @@ -2750,16 +2837,24 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( OR act2.value = '*' WITH DISTINCT aws, principal, stmt, stmt2, path_principal - // Find target roles the principal can put inline policies on and update trust policy for - MATCH path_target = (aws)--(target_role:AWSRole) + // Pre-aggregate both statements' resource values into lists so the role + // match below is evaluated once per role (in-memory `any`) instead of + // building an (all-roles x res x res2) cartesian product. MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value CONTAINS target_role.name - OR target_role.arn CONTAINS res.value + WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - OR res2.value CONTAINS target_role.name - OR target_role.arn CONTAINS res2.value + WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values + WITH aws, path_principal, res_values, res2_values, + ('*' IN res_values) AS res_wildcard, + ('*' IN res2_values) AS res2_wildcard + + // Find target roles the principal can put inline policies on and update + // trust policy for. Bind name/arn once so the `any` predicates read locals. + MATCH path_target = (aws)--(target_role:AWSRole) + WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0) + AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0) WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths