chore(ui): merge master into expired-session fix
No files matched your search
@@ -25,6 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -33,6 +34,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Enable release freeze
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: 'Tools: Release Freeze Gate'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- checks_requested
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release-freeze-gate:
|
||||
name: release-freeze-gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check release freeze status
|
||||
env:
|
||||
RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }}
|
||||
run: |
|
||||
case "${RELEASE_FREEZE}" in
|
||||
true|TRUE|True)
|
||||
echo "::error::Release freeze is active. Merges to master are temporarily blocked."
|
||||
echo "Set the RELEASE_FREEZE repository variable to false when the release is complete."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Release freeze is not active."
|
||||
;;
|
||||
esac
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from glob import glob
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
@@ -214,8 +215,10 @@ def get_security_hub_client_from_integration(
|
||||
for region in set(all_security_hub_regions):
|
||||
regions_status[region] = region in connection.enabled_regions
|
||||
|
||||
# Save regions information in the integration configuration
|
||||
# Persist the successful connection check and regions information
|
||||
with rls_transaction(tenant_id, using=MainRouter.default_db):
|
||||
integration.connected = True
|
||||
integration.connection_last_checked_at = datetime.now(tz=UTC)
|
||||
integration.configuration["regions"] = regions_status
|
||||
integration.save()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -671,6 +672,8 @@ class TestSecurityHubIntegrationUploads:
|
||||
mock_integration = MagicMock()
|
||||
mock_integration.configuration = {"send_only_fails": True}
|
||||
mock_integration.credentials = {} # Empty credentials, use provider
|
||||
mock_integration.connected = False
|
||||
mock_integration.connection_last_checked_at = None
|
||||
|
||||
# Mock tenant_id
|
||||
tenant_id = "550e8400-e29b-41d4-a716-446655440000" # Valid UUID
|
||||
@@ -723,12 +726,22 @@ class TestSecurityHubIntegrationUploads:
|
||||
# Configure the test_connection to return our mock_connection
|
||||
mock_security_hub_class.test_connection = mock_test_connection
|
||||
|
||||
checked_at_before = datetime.now(tz=UTC)
|
||||
connected, security_hub = get_security_hub_client_from_integration(
|
||||
mock_integration, tenant_id, mock_findings
|
||||
)
|
||||
checked_at_after = datetime.now(tz=UTC)
|
||||
|
||||
assert connected is True
|
||||
assert security_hub == mock_security_hub
|
||||
assert mock_integration.connected is True
|
||||
assert mock_integration.connection_last_checked_at.tzinfo is UTC
|
||||
assert (
|
||||
checked_at_before
|
||||
<= mock_integration.connection_last_checked_at
|
||||
<= checked_at_after
|
||||
)
|
||||
mock_integration.save.assert_called_once()
|
||||
|
||||
# Verify SecurityHub was called once to create the client
|
||||
assert mock_security_hub_class.call_count == 1
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
---
|
||||
title: "Attack Paths Queries"
|
||||
---
|
||||
|
||||
This guide explains how to write and maintain Prowler Attack Paths queries: the read-only openCypher queries that traverse the Cartography-ingested cloud graph to detect privilege escalation chains, network exposure, and other graph-shaped security risks.
|
||||
|
||||
<Info>
|
||||
**New to Attack Paths?** Start with the user documentation:
|
||||
- [Attack Paths](/user-guide/tutorials/prowler-app-attack-paths) - What Attack Paths detects, how to run built-in queries, and how to explore the resulting graph.
|
||||
- [Writing Custom openCypher Queries](/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries) - Run ad-hoc read-only queries from the Prowler App.
|
||||
</Info>
|
||||
|
||||
## Introduction
|
||||
|
||||
Attack Paths queries run against a property graph populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework, and enriched with Prowler findings. Every query is read-only openCypher (Version 9) so it runs on both the Neo4j and Amazon Neptune sinks.
|
||||
|
||||
Two categories of query exist, each with a different isolation model:
|
||||
|
||||
| | Predefined queries | Custom queries |
|
||||
| ------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| Where they live | `api/src/backend/api/attack_paths/queries/{provider}.py` | User-supplied through the custom query API endpoint |
|
||||
| Provider isolation | `AWSAccount {id: $provider_uid}` anchor plus path connectivity | Automatic `_Provider_{uuid}` label injection by `cypher_sanitizer.py` |
|
||||
| What to write | Chain every `MATCH` from the `aws` variable | Plain Cypher, no isolation boilerplate |
|
||||
| Internal labels | Never use | Never use (system-injected) |
|
||||
|
||||
For **predefined queries**, every node must be reachable from the `AWSAccount` root through graph traversal. That reachability is the isolation boundary.
|
||||
|
||||
For **custom queries**, the runner injects a `_Provider_{uuid}` label into every node pattern, and a post-query filter handles edge cases, so query authors write natural Cypher without isolation boilerplate.
|
||||
|
||||
The rest of this guide focuses on predefined queries, though the graph model, list-property handling, and compatibility rules apply to both.
|
||||
|
||||
## The Graph Model
|
||||
|
||||
### Cartography Schema
|
||||
|
||||
Node labels, relationship types, and properties follow the upstream Cartography schema for each provider. Do not guess them, fetch the schema for the pinned Cartography version:
|
||||
|
||||
```bash
|
||||
grep cartography api/pyproject.toml
|
||||
```
|
||||
|
||||
Then read the schema for that exact tag:
|
||||
|
||||
```text
|
||||
# Git pin (prowler-cloud/cartography@<TAG>):
|
||||
https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/<TAG>/docs/root/modules/{provider}/schema.md
|
||||
|
||||
# PyPI pin (cartography==<TAG>):
|
||||
https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/<TAG>/docs/root/modules/{provider}/schema.md
|
||||
```
|
||||
|
||||
The public schema reference for AWS is available at [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html).
|
||||
|
||||
### Prowler-Specific Additions
|
||||
|
||||
The Prowler sync task enriches the Cartography graph with the following labels and relationships. These are not part of the upstream schema:
|
||||
|
||||
| Label / Relationship | Description |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) |
|
||||
| `Internet` | Internet sentinel node used to model public exposure |
|
||||
| `CAN_ACCESS` | `(Internet)-[:CAN_ACCESS]->(resource)` exposure edge |
|
||||
| `HAS_FINDING` | `(resource)-[:HAS_FINDING]->(:ProwlerFinding)` finding link |
|
||||
| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship |
|
||||
| `STS_ASSUMEROLE_ALLOW` | Principal can assume a role |
|
||||
|
||||
### Internal Isolation Labels
|
||||
|
||||
The sync layer also adds internal labels used only for tenant and provider isolation: `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*`. These must never appear in query text, predefined or custom. The runner applies isolation automatically.
|
||||
|
||||
## Query Structure
|
||||
|
||||
### Provider Scoping Parameter
|
||||
|
||||
| Parameter | Property | Used on | Purpose |
|
||||
| --------------- | -------- | ------------ | -------------------------------------- |
|
||||
| `$provider_uid` | `id` | `AWSAccount` | Scopes the query to a specific account |
|
||||
|
||||
The runner binds `$provider_uid` automatically. Every other node is isolated by path connectivity from the `AWSAccount` anchor.
|
||||
|
||||
### Imports
|
||||
|
||||
```python
|
||||
from api.attack_paths.queries.types import (
|
||||
AttackPathsQueryAttribution,
|
||||
AttackPathsQueryDefinition,
|
||||
AttackPathsQueryParameterDefinition,
|
||||
)
|
||||
from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
|
||||
```
|
||||
|
||||
Always reference `PROWLER_FINDING_LABEL` through f-string interpolation, never hardcode `"ProwlerFinding"`.
|
||||
|
||||
### Definition Fields
|
||||
|
||||
- **id**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`.
|
||||
- **name**: short, human-friendly label. Sourced queries append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`.
|
||||
- **short_description**: one sentence, no technical permissions.
|
||||
- **description**: full technical explanation, plain text.
|
||||
- **provider**: `aws`, `azure`, `gcp`, `kubernetes`, or `github`.
|
||||
- **cypher**: f-string Cypher body. Literal `{` and `}` are escaped as `{{` and `}}`.
|
||||
- **parameters**: `parameters=[]` when the query takes no input.
|
||||
- **attribution**: optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `link` uses the lowercase ID.
|
||||
|
||||
Append the constant to the `{PROVIDER}_QUERIES` list at the bottom of the provider file.
|
||||
|
||||
## The Predefined Query Template
|
||||
|
||||
The canonical shape combines a principal walk, an optional target walk, deduplicated nodes, and a typed finding overlay:
|
||||
|
||||
```python
|
||||
AWS_QUERY_NAME = AttackPathsQueryDefinition(
|
||||
id="aws-kebab-case-name",
|
||||
name="Label (REFERENCE_ID)",
|
||||
short_description="One sentence.",
|
||||
description="Full technical explanation.",
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - REFERENCE_ID - permission",
|
||||
link="https://pathfinding.cloud/paths/reference_id_lowercase",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find principals with the source permission
|
||||
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
|
||||
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act.value) IN ['permission_lowercase', 'service:*']
|
||||
OR act.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, path_principal
|
||||
|
||||
// Pre-aggregate the statement's resource values (see "Avoiding Cartesian Products")
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Match each target once against the in-memory resource list
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- The principal walk types the `POLICY` and `STATEMENT` hops. Both are low-fan-out (each principal has a handful of policies; each policy a handful of statements), so the typed edge lets the planner cost a cheap inline filter.
|
||||
- The `(aws)--` hub hops stay anonymous. `AWSAccount` is a high-degree node that fans out to every principal, role, policy, and resource in the account; typing those edges forces the planner to enumerate from the hub and collapses performance on multi-tenant Neptune.
|
||||
- Other relationship types appear only where the file's existing queries already use one (`TRUSTS_AWS_PRINCIPAL`, `STS_ASSUMEROLE_ALLOW`, `MEMBER_AWS_GROUP`, `HAS_EXECUTION_ROLE`).
|
||||
- The finding probe is typed `:HAS_FINDING` and left undirected. The type lets Neptune apply an inline edge filter; the missing direction matches the convention of the rest of the file.
|
||||
- Collapse duplicate rows after each permission gate with `WITH DISTINCT`, carrying only the variables needed by later clauses.
|
||||
- The `RETURN` shape `paths, dpf, dpfr` is the contract the serializer and visualizer depend on. Do not change it.
|
||||
|
||||
## Avoiding Cartesian Products
|
||||
|
||||
The most common performance defect in Attack Paths queries is a Cartesian product between a target set and a policy statement's resource items. When the two are written as independent `MATCH` clauses, the planner pairs every target with every resource item before any filter runs. On accounts with many IAM principals, that multiplies into hundreds of thousands of rows and the query errors or times out.
|
||||
|
||||
### The Pattern That Causes It
|
||||
|
||||
```cypher
|
||||
// One row per (target_role x resource_item): a Cartesian product
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_role.name
|
||||
OR target_role.arn CONTAINS res.value
|
||||
```
|
||||
|
||||
The two `MATCH` clauses share no relationship, so the engine enumerates all targets multiplied by all resource items, applies a non-indexable `CONTAINS` to each pair, then expands the finding overlay for every surviving row. Cost grows with `targets × resources`, and a second constrained statement (`stmt2`) multiplies it again.
|
||||
|
||||
### The Pattern That Avoids It
|
||||
|
||||
Collect the statement's resource values into a list once, then match each target a single time against that in-memory list:
|
||||
|
||||
```cypher
|
||||
// Pre-aggregate the statement's resource values into a list
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Match each target once; bind name/arn to locals so the predicate reads them once
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
|
||||
```
|
||||
|
||||
Cost now grows with `targets + resources` (linear) rather than `targets × resources`. The rewrite is a pure algebraic identity: the result set is unchanged.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- **Aggregate resources before matching targets, not after.** `collect(DISTINCT res.value)` reduces the resource items to a single list per statement.
|
||||
- **Short-circuit the wildcard grant** with `('*' IN res_values)`. When a statement grants `*`, every target matches, so the list scan is skipped entirely.
|
||||
- **Bind `target.name` and `target.arn` to local variables** in a `WITH` before the predicate. The list comprehension then reads each once per target instead of re-reading the property store once per resource value.
|
||||
- **Use `size([... ]) > 0`, not `any(...)`.** The `any()`, `all()`, and `none()` predicate functions are not part of the openCypher specification and fail on Amazon Neptune. See [openCypher Compatibility](#opencypher-compatibility).
|
||||
- **For two-statement queries**, aggregate each statement's resources into its own list (`res_values`, `res2_values`) and combine the two `size([... ]) > 0` checks with `AND`.
|
||||
|
||||
Every IAM privilege escalation query in `aws.py` uses this pattern. The lateral-movement variants that constrain the target with a relationship (`STS_ASSUMEROLE_ALLOW`, `TRUSTS_AWS_PRINCIPAL`) already limit the target set before the resource filter, which keeps them efficient without further aggregation.
|
||||
|
||||
## Privilege Escalation Sub-Patterns
|
||||
|
||||
Four `path_target` shapes cover the common escalation types. Each shares the canonical template's `path_principal`, the resource pre-aggregation, the deduplication tail, and the `RETURN`; only the `path_target` `MATCH` and its resource predicate differ.
|
||||
|
||||
| Sub-pattern | Target | `path_target` shape | Example |
|
||||
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 |
|
||||
| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 |
|
||||
| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 |
|
||||
| PassRole plus service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: '{service}.amazonaws.com'})` | EC2-001 |
|
||||
|
||||
**Multi-permission queries** (for example PassRole plus a service-create action) add permission gates before `path_target`. Reuse the per-query counter for new variables (`act2`, `policy2`, `stmt2`) and collapse rows after each gate:
|
||||
|
||||
```cypher
|
||||
MATCH (principal)-[:POLICY]->(policy2:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {effect: 'Allow'})
|
||||
MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act2.value) IN ['service:*', 'service:createsomething']
|
||||
OR act2.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
|
||||
```
|
||||
|
||||
When a permission is an existence-only gate whose statement resource is not checked later, keep the policy and statement anonymous and carry only the variables still needed:
|
||||
|
||||
```cypher
|
||||
MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {effect: 'Allow'})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act3.value) IN ['service:*', 'service:othersomething']
|
||||
OR act3.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, path_principal
|
||||
```
|
||||
|
||||
## Network Exposure Pattern
|
||||
|
||||
The Internet node is reached through `CAN_ACCESS` from an already-scoped resource, never as a standalone lookup:
|
||||
|
||||
```python
|
||||
cypher=f"""
|
||||
// Resource scoped through the account anchor
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance)
|
||||
WHERE resource.exposed_internet = true
|
||||
|
||||
// Internet node reached through path connectivity from the resource
|
||||
OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource)
|
||||
|
||||
WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr,
|
||||
internet, can_access
|
||||
"""
|
||||
```
|
||||
|
||||
The `CAN_ACCESS` edge stays typed and directed (`-[:CAN_ACCESS]->`); that is its canonical sync-time orientation. Network-exposure queries extend the `RETURN` contract with `internet, can_access`.
|
||||
|
||||
## Working with List-Typed Properties
|
||||
|
||||
Some Cartography node properties carry a list of values: `AWSPolicyStatement.action`, `AWSPolicyStatement.resource`, `AWSPolicyStatement.notaction`, `AWSPolicyStatement.notresource`, `KMSKey.encryption_algorithms`, `CloudFrontDistribution.aliases`, the container-definition lists on `ECSContainerDefinition`, and many others. The graph models each such property as a set of child item nodes connected to the parent by a typed edge. Queries reach the values by traversing the edge; the parent does not carry the list as a single field.
|
||||
|
||||
### Naming Convention
|
||||
|
||||
For a list-typed parent property the sink stores:
|
||||
|
||||
- **Child label**: `<ParentLabel><PropertyPascal>Item`. Example: `AWSPolicyStatement.resource` becomes `AWSPolicyStatementResourceItem`.
|
||||
- **Edge type**: `HAS_<PROPERTY_UPPER>`. Example: `resource` becomes `HAS_RESOURCE`.
|
||||
- **Child property**: `value`, a single scalar string per list element. For list-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) the child carries the original dict keys as named fields per the catalog's `field_map`.
|
||||
|
||||
### Variable Naming for Child-Item Matches
|
||||
|
||||
`aws.py` uses a per-query counter for each `HAS_*` traversal so chained matches stay unambiguous. The counter resets at the top of every query.
|
||||
|
||||
| Edge | First | Second | Third |
|
||||
| ----------------- | ------ | ------- | ------- |
|
||||
| `HAS_ACTION` | `act` | `act2` | `act3` |
|
||||
| `HAS_RESOURCE` | `res` | `res2` | `res3` |
|
||||
| `HAS_NOTACTION` | `nact` | `nact2` | `nact3` |
|
||||
| `HAS_NOTRESOURCE` | `nres` | `nres2` | `nres3` |
|
||||
|
||||
### Matching an Action
|
||||
|
||||
To find statements that grant `iam:PassRole`, `iam:*`, or `*`, traverse the `HAS_ACTION` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`:
|
||||
|
||||
```cypher
|
||||
MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
|
||||
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act.value) IN ['iam:passrole', 'iam:*']
|
||||
OR act.value = '*'
|
||||
```
|
||||
|
||||
The literal-action list is case-folded with `toLower(act.value)` because IAM authors mix case (`iam:PassRole`, `iam:passrole`); the `*` wildcard never lower-cases.
|
||||
|
||||
### Matching a Resource Against a Target
|
||||
|
||||
To find statements whose resource can target a specific node, pre-aggregate the resource values and test the target against the list once (see [Avoiding Cartesian Products](#avoiding-cartesian-products)):
|
||||
|
||||
```cypher
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
|
||||
```
|
||||
|
||||
Three predicates cover the resource cases: full wildcard (`*`), a pattern containing the target name (`arn:aws:iam::*:role/admin*`), and a pattern that is a prefix or component of the actual ARN.
|
||||
|
||||
### Every-Item and Any-Item Predicates on a Custom Query
|
||||
|
||||
Custom queries can express list predicates directly with pattern comprehensions. To check whether *every* item satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached:
|
||||
|
||||
```cypher
|
||||
MATCH (stmt:AWSPolicyStatement)
|
||||
WHERE size([
|
||||
(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
|
||||
WHERE NOT toLower(a.value) STARTS WITH 's3:'
|
||||
| a
|
||||
]) = 0
|
||||
AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0
|
||||
RETURN stmt
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
To return the list of values directly, collect them from the child items:
|
||||
|
||||
```cypher
|
||||
MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
|
||||
OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
|
||||
RETURN stmt, collect(a.value) AS actions
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
### Catalog of List Properties
|
||||
|
||||
The provider catalog lives in `api/src/backend/tasks/jobs/attack_paths/provider_config.py` (`AWS_NORMALIZED_LISTS`). Beyond policy statements it includes KMS algorithms, ECS container-definition lists (`entry_point`, `command`, `links`, `dns_servers`, and others), CloudFront aliases, Inspector finding URL and vulnerability lists, and RDS event-subscription categories. To query a list property that is not in the catalog, add an entry there first so the sync layer materializes it. Properties absent from the catalog are serialized to a comma-delimited string and emit a one-time warning during sync.
|
||||
|
||||
## Working with JSON-Encoded Properties
|
||||
|
||||
Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. To keep the schema portable across graph backends, object-typed properties are stored as JSON-encoded strings:
|
||||
|
||||
```
|
||||
'{"StringEquals":{"aws:SourceAccount":"123456789012"}}'
|
||||
```
|
||||
|
||||
No JSON parser is available at query time, so use `CONTAINS` for substring checks against keys or known values:
|
||||
|
||||
```cypher
|
||||
MATCH (stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = 'Allow'
|
||||
AND stmt.condition CONTAINS '"aws:SourceAccount"'
|
||||
RETURN stmt
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
When a query needs to inspect the structured members of a condition (for example, to evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values.
|
||||
|
||||
## openCypher Compatibility
|
||||
|
||||
Queries must run on both Neo4j and Amazon Neptune. Neptune implements a subset of Cypher, so several convenient constructs are unavailable. Avoid the following:
|
||||
|
||||
| Feature | Use instead |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph |
|
||||
| Neptune extensions | Standard openCypher |
|
||||
| `any(x IN list ...)` | `size([x IN list WHERE pred]) > 0` |
|
||||
| `all(x IN list ...)` | `size([x IN list WHERE pred]) = size(list)` |
|
||||
| `none(x IN list ...)` | `size([x IN list WHERE pred]) = 0` |
|
||||
| `reduce()` | `UNWIND` plus `collect()` |
|
||||
| `FOREACH` | `WITH` plus `UNWIND` plus `SET` |
|
||||
| Regex `=~` | `toLower()` plus exact match, or `STARTS WITH` / `CONTAINS` |
|
||||
| `CALL () { UNION }` | Multi-label `OR` in `WHERE` |
|
||||
| Carried value plus aggregate expression | Project the aggregate first (`WITH principal_paths, collect(...) AS target_paths`), then combine lists in the next `WITH` |
|
||||
| `EXISTS { MATCH (pattern) WHERE pred }` | Standalone `MATCH (pattern)` plus `WHERE pred`; precede the downstream `collect(path...)` with `WITH DISTINCT <path-vars>` to dedupe the joins |
|
||||
|
||||
The carried-value-plus-aggregate rule is worth calling out because it is easy to hit. Neo4j 5.x rejects an expression that concatenates a carried list variable with an aggregate in the same projection:
|
||||
|
||||
```cypher
|
||||
// Rejected: "Aggregation column contains implicit grouping expressions"
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
```
|
||||
|
||||
Split it into two `WITH` clauses so the aggregation resolves before the concatenation:
|
||||
|
||||
```cypher
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
```
|
||||
|
||||
For list-typed properties in the catalog (action, resource, and so on), traverse the `HAS_*` edges to the child item nodes rather than reading a single field; `split(...)` and comma-string predicates do not apply.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Chain every MATCH from the account anchor.** An unanchored `MATCH (role:AWSRole)` returns roles from every provider in the graph; `MATCH (aws)--(role:AWSRole)` is scoped. A second-permission `MATCH` such as `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account subgraph.
|
||||
2. **Pre-aggregate resource lists before matching targets** to avoid Cartesian products (see [Avoiding Cartesian Products](#avoiding-cartesian-products)).
|
||||
3. **Type the finding probe.** Always `OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})`. The type lets Neptune apply an inline edge filter; an untyped probe scans every incident edge of high-degree nodes.
|
||||
4. **Comment each MATCH.** One inline `// ...` line per clause explaining its role.
|
||||
5. **Never use internal labels.** `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*` are system isolation labels and must not appear in query text.
|
||||
6. **Reach the Internet node through path connectivity** with `(internet:Internet)-[:CAN_ACCESS]->(resource)`, never as a standalone match.
|
||||
7. **Preserve the RETURN contract.** `paths, dpf, dpfr` for the standard shape; add `internet, can_access` for network-exposure queries. The serializer and visualizer depend on these names.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **ID**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`.
|
||||
- **Constant**: `UPPER_SNAKE_CASE` `{PROVIDER}_{CATEGORY}_{DESCRIPTION}`, e.g. `AWS_EC2_PRIVESC_PASSROLE_IAM`.
|
||||
|
||||
## Creating a New Query
|
||||
|
||||
New queries come from one of two input sources: a [pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) research ID (for example `ECS-001`, `GLUE-001`) or a natural-language description from the requester. The aggregated `paths.json` is too large to fetch whole; query a single path by ID:
|
||||
|
||||
```bash
|
||||
# Fetch a single path by ID
|
||||
curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \
|
||||
| jq '.[] | select(.id == "ecs-002")'
|
||||
|
||||
# List all path IDs and names
|
||||
curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \
|
||||
| jq -r '.[] | "\(.id): \(.name)"'
|
||||
```
|
||||
|
||||
Then follow these steps:
|
||||
|
||||
1. **Read the queries module first** to match the existing style:
|
||||
|
||||
```text
|
||||
api/src/backend/api/attack_paths/queries/
|
||||
├── __init__.py
|
||||
├── types.py # dataclass definitions
|
||||
├── registry.py
|
||||
└── {provider}.py
|
||||
```
|
||||
|
||||
2. **Fetch the Cartography schema for the pinned version.** Do not guess labels, properties, or relationships. See [The Graph Model](#the-graph-model).
|
||||
|
||||
3. **Build the query** from the canonical template plus the appropriate sub-pattern (privilege escalation or network exposure). Pre-aggregate resource lists, traverse `HAS_*` edges for list-typed properties, and keep the `RETURN` contract.
|
||||
|
||||
4. **Register** the constant in the `{PROVIDER}_QUERIES` list at the bottom of the provider file.
|
||||
|
||||
5. **Verify compatibility** against the [openCypher Compatibility](#opencypher-compatibility) rules, and confirm the query parses and runs on Neo4j before it reaches Neptune.
|
||||
|
||||
<Note>
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan with the `prowler_get_attack_paths_cartography_schema` tool, which guarantees that generated queries match the schema version pinned by the running Prowler release.
|
||||
</Note>
|
||||
|
||||
## Reference
|
||||
|
||||
- **pathfinding.cloud**: [github.com/DataDog/pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) (use `curl | jq`; the aggregated `paths.json` is too large for a single fetch).
|
||||
- **Cartography AWS schema**: [cartography-cncf.github.io/cartography/modules/aws/schema.html](https://cartography-cncf.github.io/cartography/modules/aws/schema.html).
|
||||
- **Neptune openCypher compliance**: [docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html](https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html).
|
||||
- **Neptune openCypher rewrites**: [docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html](https://docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html).
|
||||
- **openCypher specification**: [github.com/opencypher/openCypher](https://github.com/opencypher/openCypher).
|
||||
@@ -45,6 +45,9 @@ Prowler is constantly evolving. Contributions to checks, services, or integratio
|
||||
<Card title="Adding New Integrations" icon="link" href="/developer-guide/integrations">
|
||||
Prowler can work with other tools and platforms through integrations.
|
||||
</Card>
|
||||
<Card title="Adding New Attack Paths Queries" icon="diagram-project" href="/developer-guide/attack-paths-queries">
|
||||
Want to detect new privilege escalation or exposure patterns? Contribute read-only openCypher queries that traverse the cloud graph.
|
||||
</Card>
|
||||
<Card title="Proposing or Implementing Features" icon="lightbulb" href="https://github.com/prowler-cloud/prowler/issues/new?template=feature-request.yml">
|
||||
Propose brand-new features or enhancements to existing ones, or help implement community-requested improvements.
|
||||
</Card>
|
||||
|
||||
@@ -153,6 +153,13 @@
|
||||
"user-guide/tutorials/prowler-app-rbac"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Attack Paths",
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-app-attack-paths",
|
||||
"user-guide/tutorials/prowler-app-attack-paths-active-queries"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Compliance",
|
||||
"pages": [
|
||||
@@ -165,7 +172,6 @@
|
||||
"group": "Findings",
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-alerts",
|
||||
"user-guide/tutorials/prowler-app-attack-paths",
|
||||
"user-guide/tutorials/prowler-app-finding-groups",
|
||||
"user-guide/tutorials/prowler-app-findings-triage"
|
||||
]
|
||||
@@ -495,7 +501,8 @@
|
||||
"developer-guide/mcp-server",
|
||||
"developer-guide/ai-skills",
|
||||
"developer-guide/prowler-studio",
|
||||
"developer-guide/server-sent-events"
|
||||
"developer-guide/server-sent-events",
|
||||
"developer-guide/attack-paths-queries"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|
||||
|----------|------------|------------------------|
|
||||
| Prowler Hub | 10 tools | No |
|
||||
| Prowler Documentation | 2 tools | No |
|
||||
| Prowler Cloud, Private Cloud & Local Server | 32 tools | Yes |
|
||||
| Prowler Cloud, Private Cloud & Local Server | 39 tools | Yes |
|
||||
|
||||
## Tool Naming Convention
|
||||
|
||||
@@ -105,6 +105,23 @@ Tools for viewing compliance status and framework details across all cloud provi
|
||||
- **`prowler_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
|
||||
- **`prowler_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
|
||||
|
||||
### User Management
|
||||
|
||||
Tools for viewing the users in your tenant and identifying the authenticated user.
|
||||
|
||||
- **`prowler_list_users`** - List the users in the tenant with their names and emails
|
||||
- **`prowler_get_user`** - Get detailed information about a specific user by ID, including join date and role/membership IDs
|
||||
- **`prowler_get_current_user`** - Identify which user the current credentials authenticate as
|
||||
|
||||
### Role Management
|
||||
|
||||
Tools for browsing RBAC roles and managing the role assigned to a user. A user holds exactly one role, so setting a role replaces the one they held before.
|
||||
|
||||
- **`prowler_list_roles`** - List the roles defined in the tenant with their permission scope
|
||||
- **`prowler_get_role`** - Get detailed information about a specific role by ID, including granted capabilities, visibility scope, assigned users, and provider groups
|
||||
- **`prowler_get_user_roles`** - List the roles assigned to a specific user, with the capabilities each role grants
|
||||
- **`prowler_set_user_role`** - Set the role a user holds, replacing the role they had before (idempotent)
|
||||
|
||||
## Prowler Hub Tools
|
||||
|
||||
Access Prowler's security check catalog and compliance frameworks. **No authentication required.**
|
||||
|
||||
@@ -50,6 +50,7 @@ Full access to your Prowler deployment — Prowler Cloud, Prowler Private Cloud,
|
||||
- **Resource Inventory**: Search and view detailed information about your audited resources
|
||||
- **Muting Management**: Create and manage muting lists/rules to suppress non-relevant findings
|
||||
- **Attack Paths Analysis**: Analyze privilege escalation chains and security misconfigurations through graph-based analysis of cloud resource relationships
|
||||
- **User & Role Management**: List the users in your tenant, identify the authenticated user, browse RBAC roles, and set the role a user holds
|
||||
|
||||
### 2. Prowler Hub
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 659 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 759 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 755 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 599 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 743 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 812 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 860 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 760 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 665 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 747 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 486 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 717 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 589 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 489 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 212 KiB |