New to Attack Paths? Start with the user documentation:
- Attack Paths - What Attack Paths detects, how to run built-in queries, and how to explore the resulting graph.
- Writing Custom openCypher Queries - Run ad-hoc read-only queries from the Prowler App.
Introduction
Attack Paths queries run against a property graph populated by 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:
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: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: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
The runner binds
$provider_uid automatically. Every other node is isolated by path connectivity from the AWSAccount anchor.
Imports
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, orgithub. - 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. Thelinkuses the lowercase ID.
{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:- The principal walk types the
POLICYandSTATEMENThops. 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.AWSAccountis 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_FINDINGand 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
RETURNshapepaths, dpf, dpfris 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 independentMATCH 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
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: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.nameandtarget.arnto local variables in aWITHbefore 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, notany(...). Theany(),all(), andnone()predicate functions are not part of the openCypher specification and fail on Amazon Neptune. See openCypher Compatibility. - For two-statement queries, aggregate each statement’s resources into its own list (
res_values,res2_values) and combine the twosize([... ]) > 0checks withAND.
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
Fourpath_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.
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:
Network Exposure Pattern
The Internet node is reached throughCAN_ACCESS from an already-scoped resource, never as a standalone lookup:
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.resourcebecomesAWSPolicyStatementResourceItem. - Edge type:
HAS_<PROPERTY_UPPER>. Example:resourcebecomesHAS_RESOURCE. - Child property:
value, a single scalar string per list element. For list-of-dict properties (rare; for exampleSecretsManagerSecretVersion.tags) the child carries the original dict keys as named fields per the catalog’sfield_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.
Matching an Action
To find statements that grantiam:PassRole, iam:*, or *, traverse the HAS_ACTION edge in its own MATCH clause and apply the predicate in the attached WHERE:
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):*), 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:Catalog of List Properties
The provider catalog lives inapi/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 notablycondition on AWSPolicyStatement and S3PolicyStatement nodes. To keep the schema portable across graph backends, object-typed properties are stored as JSON-encoded strings:
CONTAINS for substring checks against keys or known 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:
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:
WITH clauses so the aggregation resolves before the concatenation:
HAS_* edges to the child item nodes rather than reading a single field; split(...) and comma-string predicates do not apply.
Best Practices
- 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-permissionMATCHsuch asMATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)is safe becauseprincipalis already bound to the account subgraph. - Pre-aggregate resource lists before matching targets to avoid Cartesian products (see Avoiding Cartesian Products).
- 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. - Comment each MATCH. One inline
// ...line per clause explaining its role. - Never use internal labels.
_ProviderResource,_AWSResource,_Tenant_*, and_Provider_*are system isolation labels and must not appear in query text. - Reach the Internet node through path connectivity with
(internet:Internet)-[:CAN_ACCESS]->(resource), never as a standalone match. - Preserve the RETURN contract.
paths, dpf, dpfrfor the standard shape; addinternet, can_accessfor 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 research ID (for exampleECS-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:
-
Read the queries module first to match the existing style:
- Fetch the Cartography schema for the pinned version. Do not guess labels, properties, or relationships. See The Graph Model.
-
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 theRETURNcontract. -
Register the constant in the
{PROVIDER}_QUERIESlist at the bottom of the provider file. - Verify compatibility against the openCypher Compatibility rules, and confirm the query parses and runs on Neo4j before it reaches Neptune.
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.Reference
- pathfinding.cloud: github.com/DataDog/pathfinding.cloud (use
curl | jq; the aggregatedpaths.jsonis too large for a single fetch). - Cartography AWS schema: cartography-cncf.github.io/cartography/modules/aws/schema.html.
- Neptune openCypher compliance: 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.
- openCypher specification: github.com/opencypher/openCypher.

