mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-07 07:57:11 +00:00
Compare commits
22 Commits
PROWLER-51
...
attack-pat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b050c917c6 | ||
|
|
67933d7d2d | ||
|
|
6fa4565ebd | ||
|
|
e426c29207 | ||
|
|
1d8d4f9325 | ||
|
|
cad44a3510 | ||
|
|
ee73e043f9 | ||
|
|
815797bc2b | ||
|
|
9cd249c561 | ||
|
|
00fe96a9f7 | ||
|
|
7c45ee1dbb | ||
|
|
d19a23f829 | ||
|
|
b071fffe57 | ||
|
|
422c55404b | ||
|
|
6c307385b0 | ||
|
|
13964ccb1c | ||
|
|
64ed526e31 | ||
|
|
2388a053ee | ||
|
|
7bb5354275 | ||
|
|
03cae9895b | ||
|
|
e398b654d4 | ||
|
|
d9e978af29 |
@@ -336,6 +336,78 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
|
||||
),
|
||||
],
|
||||
),
|
||||
# =====================================================================
|
||||
# Privilege Escalation Queries (based on pathfinding.cloud research)
|
||||
# Reference: https://github.com/DataDog/pathfinding.cloud
|
||||
# =====================================================================
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-create-policy-version",
|
||||
name="Privilege Escalation: iam:CreatePolicyVersion",
|
||||
description="Detect principals with iam:CreatePolicyVersion permission who can modify policies attached to themselves or others, enabling privilege escalation by creating a new policy version with elevated permissions. This is a self-escalation path (pathfinding.cloud: iam-001).",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
// Create a single shared virtual escalation outcome node (styled like a finding)
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-createpolicyversion',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals with iam:CreatePolicyVersion permission
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Find policies attached to the principal
|
||||
MATCH path_attached = (principal)--(attached_policy:AWSPolicy)
|
||||
WHERE attached_policy.type = 'Customer Managed'
|
||||
|
||||
// Find policy statements that grant iam:CreatePolicyVersion
|
||||
MATCH path_perms = (principal)--(perms_policy:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN stmt.action WHERE
|
||||
toLower(action) = 'iam:createpolicyversion'
|
||||
OR toLower(action) = 'iam:*'
|
||||
OR action = '*'
|
||||
)
|
||||
)
|
||||
// Check resource constraints - can they modify the attached policy?
|
||||
AND (
|
||||
any(resource IN stmt.resource WHERE
|
||||
resource = '*'
|
||||
OR attached_policy.arn CONTAINS resource
|
||||
OR resource CONTAINS attached_policy.name
|
||||
)
|
||||
)
|
||||
|
||||
// Deduplicate before creating virtual relationships
|
||||
WITH DISTINCT escalation_outcome, aws, principal, attached_policy
|
||||
|
||||
CALL apoc.create.vRelationship(principal, 'CAN_ESCALATE_TO', {
|
||||
via: 'iam:CreatePolicyVersion',
|
||||
target_policy: attached_policy.arn,
|
||||
reference: 'https://pathfinding.cloud/paths/iam-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS escalation_rel
|
||||
|
||||
// Re-match paths for visualization
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
MATCH path_attached = (principal)--(attached_policy)
|
||||
|
||||
UNWIND nodes(path_principal) + nodes(path_attached) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_principal, path_attached,
|
||||
escalation_outcome, escalation_rel,
|
||||
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-attach-role-policy-assume-role",
|
||||
name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole",
|
||||
@@ -421,6 +493,488 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-passrole-ec2",
|
||||
name="Privilege Escalation: iam:PassRole + ec2:RunInstances",
|
||||
description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service. This is a new-passrole escalation path (pathfinding.cloud: ec2-001).",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
// Create a single shared virtual EC2 instance node
|
||||
CALL apoc.create.vNode(['EC2Instance'], {
|
||||
id: 'potential-ec2-passrole',
|
||||
name: 'New EC2 Instance',
|
||||
description: 'Attacker-controlled EC2 with privileged role'
|
||||
})
|
||||
YIELD node AS ec2_node
|
||||
|
||||
// Create a single shared virtual escalation outcome node (styled like a finding)
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-passrole-ec2',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH ec2_node, escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Find statements granting iam:PassRole
|
||||
MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement)
|
||||
WHERE stmt_passrole.effect = 'Allow'
|
||||
AND any(action IN stmt_passrole.action WHERE
|
||||
toLower(action) = 'iam:passrole'
|
||||
OR toLower(action) = 'iam:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find statements granting ec2:RunInstances
|
||||
MATCH path_ec2 = (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement)
|
||||
WHERE stmt_ec2.effect = 'Allow'
|
||||
AND any(action IN stmt_ec2.action WHERE
|
||||
toLower(action) = 'ec2:runinstances'
|
||||
OR toLower(action) = 'ec2:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find roles that trust EC2 service (can be passed to EC2)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WHERE target_role.arn CONTAINS $provider_uid
|
||||
// Check if principal can pass this role
|
||||
AND any(resource IN stmt_passrole.resource WHERE
|
||||
resource = '*'
|
||||
OR target_role.arn CONTAINS resource
|
||||
OR resource CONTAINS target_role.name
|
||||
)
|
||||
|
||||
// Check if target role has elevated permissions (optional, for severity assessment)
|
||||
OPTIONAL MATCH (target_role)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement)
|
||||
WHERE role_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN role_stmt.action WHERE action = '*')
|
||||
OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
CALL apoc.create.vRelationship(principal, 'CAN_LAUNCH', {
|
||||
via: 'ec2:RunInstances + iam:PassRole'
|
||||
}, ec2_node)
|
||||
YIELD rel AS launch_rel
|
||||
|
||||
CALL apoc.create.vRelationship(ec2_node, 'ASSUMES_ROLE', {}, target_role)
|
||||
YIELD rel AS assumes_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/ec2-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
UNWIND nodes(path_principal) + nodes(path_passrole) + nodes(path_ec2) + nodes(path_target) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_principal, path_passrole, path_ec2, path_target,
|
||||
ec2_node, escalation_outcome, launch_rel, assumes_rel, grants_rel,
|
||||
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-passrole-lambda",
|
||||
name="Privilege Escalation: iam:PassRole + lambda:CreateFunction + lambda:InvokeFunction",
|
||||
description="Detect principals who can create Lambda functions with privileged IAM roles and invoke them. This allows executing code with the permissions of the passed role. This is a new-passrole escalation path (pathfinding.cloud: lambda-001).",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
// Create a single shared virtual escalation outcome node (styled like a finding)
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-passrole-lambda',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Find statements granting iam:PassRole
|
||||
MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement)
|
||||
WHERE stmt_passrole.effect = 'Allow'
|
||||
AND any(action IN stmt_passrole.action WHERE
|
||||
toLower(action) = 'iam:passrole'
|
||||
OR toLower(action) = 'iam:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find statements granting lambda:CreateFunction
|
||||
MATCH path_create = (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
|
||||
WHERE stmt_create.effect = 'Allow'
|
||||
AND any(action IN stmt_create.action WHERE
|
||||
toLower(action) = 'lambda:createfunction'
|
||||
OR toLower(action) = 'lambda:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find statements granting lambda:InvokeFunction
|
||||
MATCH path_invoke = (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement)
|
||||
WHERE stmt_invoke.effect = 'Allow'
|
||||
AND any(action IN stmt_invoke.action WHERE
|
||||
toLower(action) = 'lambda:invokefunction'
|
||||
OR toLower(action) = 'lambda:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find target roles with elevated permissions that could be passed
|
||||
MATCH (aws)--(target_role:AWSRole)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement)
|
||||
WHERE role_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN role_stmt.action WHERE action = '*')
|
||||
OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
// Deduplicate per (principal, target_role) pair
|
||||
WITH DISTINCT escalation_outcome, aws, principal, target_role
|
||||
|
||||
// Group by principal, collect target_roles
|
||||
WITH escalation_outcome, aws, principal,
|
||||
collect(DISTINCT target_role) AS target_roles,
|
||||
count(DISTINCT target_role) AS target_count
|
||||
|
||||
// Create single virtual Lambda function node per principal
|
||||
CALL apoc.create.vNode(['LambdaFunction'], {
|
||||
name: 'New Lambda Function',
|
||||
description: toString(target_count) + ' admin role(s) can be passed',
|
||||
id: principal.arn,
|
||||
target_role_count: target_count
|
||||
})
|
||||
YIELD node AS lambda_node
|
||||
|
||||
CALL apoc.create.vRelationship(principal, 'CAN_CREATE_AND_INVOKE', {
|
||||
via: 'lambda:CreateFunction + lambda:InvokeFunction + iam:PassRole'
|
||||
}, lambda_node)
|
||||
YIELD rel AS create_rel
|
||||
|
||||
// UNWIND target_roles to show which roles can be passed
|
||||
UNWIND target_roles AS target_role
|
||||
|
||||
CALL apoc.create.vRelationship(lambda_node, 'PASSES_ROLE', {}, target_role)
|
||||
YIELD rel AS pass_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/lambda-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
// Re-match path for visualization (only principal path, target_role is connected via virtual rels)
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
|
||||
RETURN path_principal,
|
||||
lambda_node, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-role-chain",
|
||||
name="Privilege Escalation: Role Assumption Chains to Admin",
|
||||
description="Detect multi-hop role assumption chains where a principal can reach an administrative role through one or more intermediate role assumptions. This traces STS_ASSUMEROLE_ALLOW relationships to find paths to privileged roles.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
// Create a single shared virtual escalation outcome node (styled like a finding)
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-rolechain',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Find role assumption chains (1-5 hops) to roles with elevated permissions
|
||||
MATCH path_chain = (principal)-[:STS_ASSUMEROLE_ALLOW*1..5]->(target_role:AWSRole)
|
||||
|
||||
// Target role must have administrative permissions
|
||||
MATCH path_admin = (target_role)--(admin_policy:AWSPolicy)--(admin_stmt:AWSPolicyStatement)
|
||||
WHERE admin_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN admin_stmt.action WHERE action = '*')
|
||||
OR any(action IN admin_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
OR any(action IN admin_stmt.action WHERE toLower(action) CONTAINS 'admin')
|
||||
)
|
||||
|
||||
// Deduplicate and calculate chain length before creating virtual relationships
|
||||
WITH DISTINCT escalation_outcome, aws, principal, target_role, path_chain,
|
||||
length(path_chain) as chain_length,
|
||||
[node in nodes(path_chain) | node.name] as chain_nodes
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ADMIN', {
|
||||
hops: chain_length,
|
||||
technique: 'sts:AssumeRole chain (' + toString(chain_length) + ' hops)',
|
||||
reference: 'https://pathfinding.cloud/paths/sts-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS admin_rel
|
||||
|
||||
// Re-match paths for visualization
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
|
||||
RETURN path_principal, path_chain,
|
||||
escalation_outcome, admin_rel,
|
||||
chain_length, chain_nodes
|
||||
ORDER BY chain_length ASC
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-ecs-privesc-passrole-task",
|
||||
name="Privilege Escalation: ECS Task Definition with PassRole",
|
||||
description="Detect principals that can escalate privileges by passing a role to an ECS task definition and creating a service. The attacker can register a task definition with an arbitrary role, then access those role credentials from the running container.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-ecs',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator (ECS)',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Principal can assume roles (up to 2 hops for flexibility)
|
||||
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
|
||||
WITH escalation_outcome, aws, principal, path_principal, path_assume,
|
||||
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
|
||||
|
||||
// Find iam:PassRole permission
|
||||
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
|
||||
WHERE passrole_stmt.effect = 'Allow'
|
||||
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
|
||||
|
||||
// Find ECS task definition permissions
|
||||
MATCH (effective_principal)--(ecs_policy:AWSPolicy)--(ecs_stmt:AWSPolicyStatement)
|
||||
WHERE ecs_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN ecs_stmt.action WHERE toLower(action) = 'ecs:registertaskdefinition' OR action = '*' OR toLower(action) = 'ecs:*')
|
||||
)
|
||||
AND (
|
||||
any(action IN ecs_stmt.action WHERE toLower(action) = 'ecs:createservice' OR toLower(action) = 'ecs:runtask' OR action = '*' OR toLower(action) = 'ecs:*')
|
||||
)
|
||||
|
||||
// Find target roles with elevated permissions that could be passed
|
||||
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
|
||||
WHERE target_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN target_stmt.action WHERE action = '*')
|
||||
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
// Deduplicate per (principal, target_role) pair
|
||||
WITH DISTINCT escalation_outcome, aws, principal, target_role
|
||||
|
||||
// Group by principal, collect target_roles
|
||||
WITH escalation_outcome, aws, principal,
|
||||
collect(DISTINCT target_role) AS target_roles,
|
||||
count(DISTINCT target_role) AS target_count
|
||||
|
||||
// Create single virtual ECS task node per principal
|
||||
CALL apoc.create.vNode(['ECSTask'], {
|
||||
name: 'New Task Definition',
|
||||
description: toString(target_count) + ' admin role(s) can be passed',
|
||||
id: principal.arn,
|
||||
target_role_count: target_count
|
||||
})
|
||||
YIELD node AS ecs_task
|
||||
|
||||
// Connect from principal (not effective_principal) to keep graph connected
|
||||
CALL apoc.create.vRelationship(principal, 'CREATES_TASK', {
|
||||
permissions: ['iam:PassRole', 'ecs:RegisterTaskDefinition', 'ecs:CreateService'],
|
||||
technique: 'new-passrole'
|
||||
}, ecs_task)
|
||||
YIELD rel AS create_rel
|
||||
|
||||
// UNWIND target_roles to show which roles can be passed
|
||||
UNWIND target_roles AS target_role
|
||||
|
||||
CALL apoc.create.vRelationship(ecs_task, 'PASSES_ROLE', {}, target_role)
|
||||
YIELD rel AS pass_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/ecs-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
// Re-match path for visualization (only principal path, target_role is connected via virtual rels)
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
|
||||
RETURN path_principal,
|
||||
ecs_task, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-ssm-privesc-start-session",
|
||||
name="Privilege Escalation: SSM Start Session to EC2 Role",
|
||||
description="Detect principals that can escalate privileges by using SSM StartSession to access an EC2 instance and inherit its IAM role credentials. This is an existing-passrole technique where the role is already attached to the instance.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-ssm',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator (SSM)',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Principal can assume roles (up to 2 hops)
|
||||
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
|
||||
WITH escalation_outcome, principal, path_principal, path_assume,
|
||||
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
|
||||
|
||||
// Find ssm:StartSession permission
|
||||
MATCH (effective_principal)--(ssm_policy:AWSPolicy)--(ssm_stmt:AWSPolicyStatement)
|
||||
WHERE ssm_stmt.effect = 'Allow'
|
||||
AND any(action IN ssm_stmt.action WHERE toLower(action) = 'ssm:startsession' OR action = '*' OR toLower(action) = 'ssm:*')
|
||||
|
||||
// Find EC2 instances with instance profiles
|
||||
MATCH (aws)--(instance:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(instance_role:AWSRole)
|
||||
|
||||
// Instance role should have elevated permissions
|
||||
MATCH (instance_role)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
|
||||
WHERE target_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN target_stmt.action WHERE action = '*')
|
||||
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
OR any(action IN target_stmt.action WHERE toLower(action) CONTAINS 'admin')
|
||||
)
|
||||
|
||||
// Deduplicate before creating virtual relationships
|
||||
WITH DISTINCT escalation_outcome, aws, principal, effective_principal, instance, instance_role
|
||||
|
||||
CALL apoc.create.vRelationship(effective_principal, 'SSM_ACCESS', {
|
||||
permissions: ['ssm:StartSession'],
|
||||
technique: 'existing-passrole'
|
||||
}, instance)
|
||||
YIELD rel AS ssm_rel
|
||||
|
||||
CALL apoc.create.vRelationship(instance_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/ssm-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
// Re-match paths for visualization
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
MATCH path_ec2 = (aws)--(instance)-[:STS_ASSUMEROLE_ALLOW]->(instance_role)
|
||||
|
||||
RETURN path_principal, path_ec2,
|
||||
escalation_outcome, ssm_rel, grants_rel
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-glue-privesc-passrole-dev-endpoint",
|
||||
name="Privilege Escalation: Glue Dev Endpoint with PassRole",
|
||||
description="Detect principals that can escalate privileges by passing a role to a Glue development endpoint. The attacker creates a dev endpoint with an arbitrary role attached, then accesses those credentials through the endpoint.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-glue',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator (Glue)',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Principal can assume roles (up to 2 hops)
|
||||
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
|
||||
WITH escalation_outcome, aws, principal, path_principal, path_assume,
|
||||
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
|
||||
|
||||
// Find iam:PassRole permission
|
||||
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
|
||||
WHERE passrole_stmt.effect = 'Allow'
|
||||
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
|
||||
|
||||
// Find Glue CreateDevEndpoint permission
|
||||
MATCH (effective_principal)--(glue_policy:AWSPolicy)--(glue_stmt:AWSPolicyStatement)
|
||||
WHERE glue_stmt.effect = 'Allow'
|
||||
AND any(action IN glue_stmt.action WHERE toLower(action) = 'glue:createdevendpoint' OR action = '*' OR toLower(action) = 'glue:*')
|
||||
|
||||
// Find target roles with elevated permissions that could be passed
|
||||
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
|
||||
WHERE target_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN target_stmt.action WHERE action = '*')
|
||||
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
// Deduplicate per (principal, target_role) pair
|
||||
WITH DISTINCT escalation_outcome, aws, principal, target_role
|
||||
|
||||
// Group by principal, collect target_roles
|
||||
WITH escalation_outcome, aws, principal,
|
||||
collect(DISTINCT target_role) AS target_roles,
|
||||
count(DISTINCT target_role) AS target_count
|
||||
|
||||
// Create single virtual Glue endpoint node per principal
|
||||
CALL apoc.create.vNode(['GlueDevEndpoint'], {
|
||||
name: 'New Dev Endpoint',
|
||||
description: toString(target_count) + ' admin role(s) can be passed',
|
||||
id: principal.arn,
|
||||
target_role_count: target_count
|
||||
})
|
||||
YIELD node AS glue_endpoint
|
||||
|
||||
// Connect from principal (not effective_principal) to keep graph connected
|
||||
CALL apoc.create.vRelationship(principal, 'CREATES_ENDPOINT', {
|
||||
permissions: ['iam:PassRole', 'glue:CreateDevEndpoint'],
|
||||
technique: 'new-passrole'
|
||||
}, glue_endpoint)
|
||||
YIELD rel AS create_rel
|
||||
|
||||
// UNWIND target_roles to show which roles can be passed
|
||||
UNWIND target_roles AS target_role
|
||||
|
||||
CALL apoc.create.vRelationship(glue_endpoint, 'PASSES_ROLE', {}, target_role)
|
||||
YIELD rel AS pass_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/glue-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
// Re-match path for visualization (only principal path, target_role is connected via virtual rels)
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
|
||||
RETURN path_principal,
|
||||
glue_endpoint, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-bedrock-privesc-passrole-code-interpreter",
|
||||
name="Privilege Escalation: Bedrock Code Interpreter with PassRole",
|
||||
@@ -515,6 +1069,92 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-cloudformation-privesc-passrole-create-stack",
|
||||
name="Privilege Escalation: CloudFormation Stack with PassRole",
|
||||
description="Detect principals that can escalate privileges by passing a role to a CloudFormation stack. The attacker creates a stack with an arbitrary role, allowing CloudFormation to perform actions as that role when creating resources.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-cfn',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator (CloudFormation)',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Principal can assume roles (up to 2 hops)
|
||||
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
|
||||
WITH escalation_outcome, aws, principal, path_principal, path_assume,
|
||||
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
|
||||
|
||||
// Find iam:PassRole permission
|
||||
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
|
||||
WHERE passrole_stmt.effect = 'Allow'
|
||||
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
|
||||
|
||||
// Find CloudFormation CreateStack permission
|
||||
MATCH (effective_principal)--(cfn_policy:AWSPolicy)--(cfn_stmt:AWSPolicyStatement)
|
||||
WHERE cfn_stmt.effect = 'Allow'
|
||||
AND any(action IN cfn_stmt.action WHERE toLower(action) = 'cloudformation:createstack' OR action = '*' OR toLower(action) = 'cloudformation:*')
|
||||
|
||||
// Find target roles with elevated permissions that could be passed
|
||||
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
|
||||
WHERE target_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN target_stmt.action WHERE action = '*')
|
||||
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
// Deduplicate per (principal, target_role) pair
|
||||
WITH DISTINCT escalation_outcome, aws, principal, target_role
|
||||
|
||||
// Group by principal, collect target_roles
|
||||
WITH escalation_outcome, aws, principal,
|
||||
collect(DISTINCT target_role) AS target_roles,
|
||||
count(DISTINCT target_role) AS target_count
|
||||
|
||||
// Create single virtual CloudFormation stack node per principal
|
||||
CALL apoc.create.vNode(['CloudFormationStack'], {
|
||||
name: 'New Stack',
|
||||
description: toString(target_count) + ' admin role(s) can be passed',
|
||||
id: principal.arn,
|
||||
target_role_count: target_count
|
||||
})
|
||||
YIELD node AS cfn_stack
|
||||
|
||||
// Connect from principal (not effective_principal) to keep graph connected
|
||||
CALL apoc.create.vRelationship(principal, 'CREATES_STACK', {
|
||||
permissions: ['iam:PassRole', 'cloudformation:CreateStack'],
|
||||
technique: 'new-passrole'
|
||||
}, cfn_stack)
|
||||
YIELD rel AS create_rel
|
||||
|
||||
// UNWIND target_roles to show which roles can be passed
|
||||
UNWIND target_roles AS target_role
|
||||
|
||||
CALL apoc.create.vRelationship(cfn_stack, 'PASSES_ROLE', {}, target_role)
|
||||
YIELD rel AS pass_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/cloudformation-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
// Re-match path for visualization (only principal path, target_role is connected via virtual rels)
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
|
||||
RETURN path_principal,
|
||||
cfn_stack, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
api-dev:
|
||||
hostname: "prowler-api"
|
||||
image: prowler-api-dev
|
||||
# image: prowler-api-dev
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
@@ -115,7 +115,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
worker-dev:
|
||||
image: prowler-api-dev
|
||||
# image: prowler-api-dev
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
@@ -142,7 +142,7 @@ services:
|
||||
- "worker"
|
||||
|
||||
worker-beat:
|
||||
image: prowler-api-dev
|
||||
# image: prowler-api-dev
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -175,63 +175,47 @@ function generateLegendItems(
|
||||
hasFindings: boolean,
|
||||
): LegendItem[] {
|
||||
const items: LegendItem[] = [];
|
||||
const seenTypes = new Set<string>();
|
||||
|
||||
// Add severity items if there are findings
|
||||
if (hasFindings) {
|
||||
items.push(...severityLegendItems);
|
||||
}
|
||||
|
||||
// Helper to format unknown node types (e.g., "AWSPolicyStatement" -> "AWS Policy Statement")
|
||||
const formatNodeTypeName = (nodeType: string): string => {
|
||||
return nodeType
|
||||
.replace(/([A-Z])/g, " $1") // Add space before capitals
|
||||
.replace(/^ /, "") // Remove leading space
|
||||
.replace(/AWS /g, "AWS ") // Keep AWS together
|
||||
.replace(/EC2 /g, "EC2 ") // Keep EC2 together
|
||||
.replace(/S3 /g, "S3 ") // Keep S3 together
|
||||
.replace(/IAM /g, "IAM ") // Keep IAM together
|
||||
.replace(/IP /g, "IP ") // Keep IP together
|
||||
.trim();
|
||||
};
|
||||
// Check for Internet node
|
||||
const hasInternet = nodeTypes.some(
|
||||
(type) => type.toLowerCase() === "internet",
|
||||
);
|
||||
|
||||
nodeTypes.forEach((nodeType) => {
|
||||
if (seenTypes.has(nodeType)) return;
|
||||
seenTypes.add(nodeType);
|
||||
|
||||
// Skip findings - we show severity colors instead
|
||||
const isFinding = nodeType.toLowerCase().includes("finding");
|
||||
if (isFinding) return;
|
||||
|
||||
const description = nodeTypeDescriptions[nodeType];
|
||||
|
||||
// Determine shape based on node type
|
||||
const isInternet = nodeType.toLowerCase() === "internet";
|
||||
const shape: "rectangle" | "hexagon" | "cloud" = isInternet
|
||||
? "cloud"
|
||||
: "rectangle";
|
||||
|
||||
if (description) {
|
||||
items.push({
|
||||
label: description.name,
|
||||
color: getNodeColor([nodeType]),
|
||||
borderColor: getNodeBorderColor([nodeType]),
|
||||
description: description.description,
|
||||
shape,
|
||||
});
|
||||
} else {
|
||||
// Format unknown node types nicely
|
||||
const formattedName = formatNodeTypeName(nodeType);
|
||||
items.push({
|
||||
label: formattedName,
|
||||
color: getNodeColor([nodeType]),
|
||||
borderColor: getNodeBorderColor([nodeType]),
|
||||
description: `${formattedName} node`,
|
||||
shape,
|
||||
});
|
||||
}
|
||||
// Check for any resource nodes (non-finding, non-internet)
|
||||
const hasResources = nodeTypes.some((type) => {
|
||||
const isFinding = type.toLowerCase().includes("finding");
|
||||
const isPrivilegeEscalation = type === "PrivilegeEscalation";
|
||||
const isInternet = type.toLowerCase() === "internet";
|
||||
return !isFinding && !isPrivilegeEscalation && !isInternet;
|
||||
});
|
||||
|
||||
// Add a single "Resource" item for all resource types
|
||||
if (hasResources) {
|
||||
items.push({
|
||||
label: "Resource",
|
||||
color: GRAPH_NODE_COLORS.default,
|
||||
borderColor: GRAPH_NODE_BORDER_COLORS.default,
|
||||
description: "Cloud infrastructure resource",
|
||||
shape: "rectangle",
|
||||
});
|
||||
}
|
||||
|
||||
// Add Internet node if present
|
||||
if (hasInternet) {
|
||||
items.push({
|
||||
label: "Internet",
|
||||
color: getNodeColor(["Internet"]),
|
||||
borderColor: getNodeBorderColor(["Internet"]),
|
||||
description: "Internet gateway or public access",
|
||||
shape: "cloud",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -395,9 +379,10 @@ interface GraphLegendProps {
|
||||
export const GraphLegend = ({ data }: GraphLegendProps) => {
|
||||
const nodeTypes = extractNodeTypes(data?.nodes);
|
||||
|
||||
// Check if there are any findings in the data
|
||||
const hasFindings = nodeTypes.some((type) =>
|
||||
type.toLowerCase().includes("finding"),
|
||||
// Check if there are any findings or privilege escalations in the data
|
||||
const hasFindings = nodeTypes.some(
|
||||
(type) =>
|
||||
type.toLowerCase().includes("finding") || type === "PrivilegeEscalation",
|
||||
);
|
||||
|
||||
const legendItems = generateLegendItems(nodeTypes, hasFindings);
|
||||
|
||||
@@ -8,17 +8,29 @@ import type {
|
||||
GraphState,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
interface GraphStore extends GraphState {
|
||||
interface FilteredViewState {
|
||||
isFilteredView: boolean;
|
||||
filteredNodeId: string | null;
|
||||
fullData: AttackPathGraphData | null; // Original data before filtering
|
||||
}
|
||||
|
||||
interface GraphStore extends GraphState, FilteredViewState {
|
||||
setGraphData: (data: AttackPathGraphData) => void;
|
||||
setSelectedNodeId: (nodeId: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setZoom: (zoomLevel: number) => void;
|
||||
setPan: (panX: number, panY: number) => void;
|
||||
setFilteredView: (
|
||||
isFiltered: boolean,
|
||||
nodeId: string | null,
|
||||
filteredData: AttackPathGraphData | null,
|
||||
fullData: AttackPathGraphData | null,
|
||||
) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: GraphState = {
|
||||
const initialState: GraphState & FilteredViewState = {
|
||||
data: null,
|
||||
selectedNodeId: null,
|
||||
loading: false,
|
||||
@@ -26,22 +38,136 @@ const initialState: GraphState = {
|
||||
zoomLevel: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
isFilteredView: false,
|
||||
filteredNodeId: null,
|
||||
fullData: null,
|
||||
};
|
||||
|
||||
const useGraphStore = create<GraphStore>((set) => ({
|
||||
...initialState,
|
||||
setGraphData: (data) => set({ data, error: null }),
|
||||
setGraphData: (data) => set({ data, fullData: null, error: null, isFilteredView: false, filteredNodeId: null }),
|
||||
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setZoom: (zoomLevel) => set({ zoomLevel }),
|
||||
setPan: (panX, panY) => set({ panX, panY }),
|
||||
setFilteredView: (isFiltered, nodeId, filteredData, fullData) =>
|
||||
set({ isFilteredView: isFiltered, filteredNodeId: nodeId, data: filteredData, fullData, selectedNodeId: nodeId }),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Helper to get edge source/target ID from string or object
|
||||
*/
|
||||
function getEdgeNodeId(nodeRef: string | object): string {
|
||||
if (typeof nodeRef === "string") {
|
||||
return nodeRef;
|
||||
}
|
||||
return (nodeRef as GraphNode).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a filtered subgraph containing only the path through the target node.
|
||||
* This follows the directed graph structure of attack paths:
|
||||
* - Upstream: traces back to the root (AWS Account)
|
||||
* - Downstream: traces forward to leaf nodes
|
||||
* - Also includes findings connected to the selected node
|
||||
*/
|
||||
function computeFilteredSubgraph(
|
||||
fullData: AttackPathGraphData,
|
||||
targetNodeId: string,
|
||||
): AttackPathGraphData {
|
||||
const nodes = fullData.nodes;
|
||||
const edges = fullData.edges || [];
|
||||
|
||||
// Build directed adjacency lists
|
||||
const forwardEdges = new Map<string, Set<string>>(); // source -> targets
|
||||
const backwardEdges = new Map<string, Set<string>>(); // target -> sources
|
||||
nodes.forEach((node) => {
|
||||
forwardEdges.set(node.id, new Set());
|
||||
backwardEdges.set(node.id, new Set());
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
const sourceId = getEdgeNodeId(edge.source);
|
||||
const targetId = getEdgeNodeId(edge.target);
|
||||
forwardEdges.get(sourceId)?.add(targetId);
|
||||
backwardEdges.get(targetId)?.add(sourceId);
|
||||
});
|
||||
|
||||
const visibleNodeIds = new Set<string>();
|
||||
visibleNodeIds.add(targetNodeId);
|
||||
|
||||
// Traverse upstream (backward) - find all ancestors
|
||||
const traverseUpstream = (nodeId: string) => {
|
||||
const sources = backwardEdges.get(nodeId);
|
||||
if (sources) {
|
||||
sources.forEach((sourceId) => {
|
||||
if (!visibleNodeIds.has(sourceId)) {
|
||||
visibleNodeIds.add(sourceId);
|
||||
traverseUpstream(sourceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Traverse downstream (forward) - find all descendants
|
||||
const traverseDownstream = (nodeId: string) => {
|
||||
const targets = forwardEdges.get(nodeId);
|
||||
if (targets) {
|
||||
targets.forEach((targetId) => {
|
||||
if (!visibleNodeIds.has(targetId)) {
|
||||
visibleNodeIds.add(targetId);
|
||||
traverseDownstream(targetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Start traversal from the target node
|
||||
traverseUpstream(targetNodeId);
|
||||
traverseDownstream(targetNodeId);
|
||||
|
||||
// Also include findings directly connected to the selected node
|
||||
edges.forEach((edge) => {
|
||||
const sourceId = getEdgeNodeId(edge.source);
|
||||
const targetId = getEdgeNodeId(edge.target);
|
||||
const sourceNode = nodes.find((n) => n.id === sourceId);
|
||||
const targetNode = nodes.find((n) => n.id === targetId);
|
||||
|
||||
const sourceIsFinding = sourceNode?.labels.some((l) =>
|
||||
l.toLowerCase().includes("finding"),
|
||||
);
|
||||
const targetIsFinding = targetNode?.labels.some((l) =>
|
||||
l.toLowerCase().includes("finding"),
|
||||
);
|
||||
|
||||
// Include findings connected to the selected node
|
||||
if (sourceId === targetNodeId && targetIsFinding) {
|
||||
visibleNodeIds.add(targetId);
|
||||
}
|
||||
if (targetId === targetNodeId && sourceIsFinding) {
|
||||
visibleNodeIds.add(sourceId);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter nodes and edges to only include visible ones
|
||||
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
|
||||
const filteredEdges = edges.filter((edge) => {
|
||||
const sourceId = getEdgeNodeId(edge.source);
|
||||
const targetId = getEdgeNodeId(edge.target);
|
||||
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: filteredNodes,
|
||||
edges: filteredEdges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing graph visualization state
|
||||
* Handles graph data, node selection, zoom/pan, and loading states
|
||||
* Handles graph data, node selection, zoom/pan, loading states, and filtered view
|
||||
*/
|
||||
export const useGraphState = () => {
|
||||
const store = useGraphStore();
|
||||
@@ -86,10 +212,46 @@ export const useGraphState = () => {
|
||||
const clearGraph = () => {
|
||||
store.setGraphData({ nodes: [], edges: [] });
|
||||
store.setSelectedNodeId(null);
|
||||
store.setFilteredView(false, null, null, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Enter filtered view mode - redraws graph with only the selected path
|
||||
* Stores full data so we can restore it when exiting filtered view
|
||||
*/
|
||||
const enterFilteredView = (nodeId: string) => {
|
||||
if (!store.data) return;
|
||||
|
||||
// Use fullData if we're already in filtered view, otherwise use current data
|
||||
const sourceData = store.fullData || store.data;
|
||||
const filteredData = computeFilteredSubgraph(sourceData, nodeId);
|
||||
store.setFilteredView(true, nodeId, filteredData, sourceData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Exit filtered view mode - restore full graph data
|
||||
*/
|
||||
const exitFilteredView = () => {
|
||||
if (!store.isFilteredView || !store.fullData) return;
|
||||
store.setFilteredView(false, null, store.fullData, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the node that was used to filter the view
|
||||
*/
|
||||
const getFilteredNode = (): GraphNode | null => {
|
||||
if (!store.isFilteredView || !store.filteredNodeId) return null;
|
||||
// Look in fullData since that's where the original node data is
|
||||
const sourceData = store.fullData || store.data;
|
||||
if (!sourceData) return null;
|
||||
return (
|
||||
sourceData.nodes.find((node) => node.id === store.filteredNodeId) || null
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
data: store.data,
|
||||
fullData: store.fullData,
|
||||
selectedNodeId: store.selectedNodeId,
|
||||
selectedNode: getSelectedNode(),
|
||||
loading: store.loading,
|
||||
@@ -97,6 +259,9 @@ export const useGraphState = () => {
|
||||
zoomLevel: store.zoomLevel,
|
||||
panX: store.panX,
|
||||
panY: store.panY,
|
||||
isFilteredView: store.isFilteredView,
|
||||
filteredNodeId: store.filteredNodeId,
|
||||
filteredNode: getFilteredNode(),
|
||||
updateGraphData,
|
||||
selectNode,
|
||||
startLoading,
|
||||
@@ -105,5 +270,7 @@ export const useGraphState = () => {
|
||||
updateZoomAndPan,
|
||||
resetGraph,
|
||||
clearGraph,
|
||||
enterFilteredView,
|
||||
exitFilteredView,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -66,7 +66,9 @@ export const getNodeColor = (
|
||||
properties?: Record<string, unknown>,
|
||||
): string => {
|
||||
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
|
||||
if (isFinding && properties?.severity) {
|
||||
const isPrivilegeEscalation = labels.some((l) => l === "PrivilegeEscalation");
|
||||
|
||||
if ((isFinding || isPrivilegeEscalation) && properties?.severity) {
|
||||
const severity = String(properties.severity).toLowerCase();
|
||||
if (severity === "critical") return GRAPH_NODE_COLORS.critical;
|
||||
if (severity === "high") return GRAPH_NODE_COLORS.high;
|
||||
@@ -99,7 +101,9 @@ export const getNodeBorderColor = (
|
||||
properties?: Record<string, unknown>,
|
||||
): string => {
|
||||
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
|
||||
if (isFinding && properties?.severity) {
|
||||
const isPrivilegeEscalation = labels.some((l) => l === "PrivilegeEscalation");
|
||||
|
||||
if ((isFinding || isPrivilegeEscalation) && properties?.severity) {
|
||||
const severity = String(properties.severity).toLowerCase();
|
||||
if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical;
|
||||
if (severity === "high") return GRAPH_NODE_BORDER_COLORS.high;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* Upstream: follows only ONE parent path (first parent at each level) to avoid lighting up siblings
|
||||
* Downstream: follows ALL children recursively
|
||||
*
|
||||
* Uses pre-built adjacency maps for O(1) lookups instead of O(n) array searches per traversal step.
|
||||
*
|
||||
* @param nodeId - The starting node ID
|
||||
* @param edges - Array of edges with sourceId and targetId
|
||||
* @returns Set of edge IDs in the format "sourceId-targetId"
|
||||
@@ -15,6 +17,23 @@ export const getPathEdges = (
|
||||
nodeId: string,
|
||||
edges: Array<{ sourceId: string; targetId: string }>,
|
||||
): Set<string> => {
|
||||
// Build adjacency maps once - O(n)
|
||||
const parentMap = new Map<string, { sourceId: string; targetId: string }>();
|
||||
const childrenMap = new Map<
|
||||
string,
|
||||
Array<{ sourceId: string; targetId: string }>
|
||||
>();
|
||||
|
||||
edges.forEach((edge) => {
|
||||
// First parent only (matches original behavior of find())
|
||||
if (!parentMap.has(edge.targetId)) {
|
||||
parentMap.set(edge.targetId, edge);
|
||||
}
|
||||
const children = childrenMap.get(edge.sourceId) || [];
|
||||
children.push(edge);
|
||||
childrenMap.set(edge.sourceId, children);
|
||||
});
|
||||
|
||||
const pathEdgeIds = new Set<string>();
|
||||
const visitedNodes = new Set<string>();
|
||||
|
||||
@@ -24,8 +43,7 @@ export const getPathEdges = (
|
||||
if (visitedNodes.has(`up-${currentNodeId}`)) return;
|
||||
visitedNodes.add(`up-${currentNodeId}`);
|
||||
|
||||
// Find the first parent edge only
|
||||
const parentEdge = edges.find((edge) => edge.targetId === currentNodeId);
|
||||
const parentEdge = parentMap.get(currentNodeId); // O(1) lookup
|
||||
if (parentEdge) {
|
||||
pathEdgeIds.add(`${parentEdge.sourceId}-${parentEdge.targetId}`);
|
||||
traverseUpstream(parentEdge.sourceId);
|
||||
@@ -37,11 +55,10 @@ export const getPathEdges = (
|
||||
if (visitedNodes.has(`down-${currentNodeId}`)) return;
|
||||
visitedNodes.add(`down-${currentNodeId}`);
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (edge.sourceId === currentNodeId) {
|
||||
pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`);
|
||||
traverseDownstream(edge.targetId);
|
||||
}
|
||||
const children = childrenMap.get(currentNodeId) || []; // O(1) lookup
|
||||
children.forEach((edge) => {
|
||||
pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`);
|
||||
traverseDownstream(edge.targetId);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Maximize2, X } from "lucide-react";
|
||||
import { ArrowLeft, Maximize2, X } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
@@ -233,15 +233,15 @@ export default function AttackPathAnalysisPage() {
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
graphState.selectNode(node.id);
|
||||
// Enter filtered view showing only paths containing this node
|
||||
graphState.enterFilteredView(node.id);
|
||||
|
||||
// Only scroll to details if it's a finding node
|
||||
// For findings, also scroll to the details section
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
|
||||
if (isFinding) {
|
||||
// Scroll to node details section after a short delay
|
||||
setTimeout(() => {
|
||||
nodeDetailsRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
@@ -251,6 +251,10 @@ export default function AttackPathAnalysisPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToFullView = () => {
|
||||
graphState.exitFilteredView();
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
graphState.selectNode(null);
|
||||
};
|
||||
@@ -371,18 +375,50 @@ export default function AttackPathAnalysisPage() {
|
||||
<>
|
||||
{/* Info message and controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div
|
||||
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
|
||||
role="status"
|
||||
aria-label="Graph interaction instructions"
|
||||
>
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
💡
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
Click on any resource node to view its related findings
|
||||
</span>
|
||||
</div>
|
||||
{graphState.isFilteredView ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleBackToFullView}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
aria-label="Return to full graph view"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back to Full View
|
||||
</Button>
|
||||
<div
|
||||
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
|
||||
role="status"
|
||||
aria-label="Filtered view active"
|
||||
>
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
🔍
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
Showing paths for:{" "}
|
||||
<strong>
|
||||
{graphState.filteredNode?.properties?.name ||
|
||||
graphState.filteredNode?.properties?.id ||
|
||||
"Selected node"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
|
||||
role="status"
|
||||
aria-label="Graph interaction instructions"
|
||||
>
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
💡
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
Click on any node to filter and view its connected paths
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph controls and fullscreen button together */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -441,6 +477,7 @@ export default function AttackPathAnalysisPage() {
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
/>
|
||||
</div>
|
||||
{/* Node Detail Panel - Side by side */}
|
||||
@@ -509,6 +546,7 @@ export default function AttackPathAnalysisPage() {
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,10 +42,10 @@
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@langchain/core",
|
||||
"from": "0.3.77",
|
||||
"to": "0.3.78",
|
||||
"from": "0.3.78",
|
||||
"to": "0.3.77",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-11-03T07:43:34.628Z"
|
||||
"generatedAt": "2026-01-07T08:46:39.109Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -415,6 +415,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "require-in-the-middle",
|
||||
"from": "8.0.1",
|
||||
"to": "8.0.1",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-07T12:09:03.204Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "rss-parser",
|
||||
|
||||
55
ui/package-lock.json
generated
55
ui/package-lock.json
generated
@@ -61,6 +61,7 @@
|
||||
"react-hook-form": "7.62.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"recharts": "2.15.4",
|
||||
"require-in-the-middle": "8.0.1",
|
||||
"rss-parser": "3.13.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.33.5",
|
||||
@@ -5273,7 +5274,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.203.0.tgz",
|
||||
"integrity": "sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.203.0",
|
||||
"import-in-the-middle": "^1.8.1",
|
||||
@@ -5495,6 +5495,20 @@
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/instrumentation-ioredis/node_modules/require-in-the-middle": {
|
||||
"version": "7.5.2",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
|
||||
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"module-details-from-path": "^1.0.3",
|
||||
"resolve": "^1.22.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/instrumentation-kafkajs": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.13.0.tgz",
|
||||
@@ -5696,6 +5710,20 @@
|
||||
"@opentelemetry/api": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/instrumentation/node_modules/require-in-the-middle": {
|
||||
"version": "7.5.2",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
|
||||
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"module-details-from-path": "^1.0.3",
|
||||
"resolve": "^1.22.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/redis-common": {
|
||||
"version": "0.38.2",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz",
|
||||
@@ -5848,6 +5876,20 @@
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/instrumentation/node_modules/require-in-the-middle": {
|
||||
"version": "7.5.2",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
|
||||
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"module-details-from-path": "^1.0.3",
|
||||
"resolve": "^1.22.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/instrumentation/node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -22881,17 +22923,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/require-in-the-middle": {
|
||||
"version": "7.5.2",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
|
||||
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
|
||||
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"module-details-from-path": "^1.0.3",
|
||||
"resolve": "^1.22.8"
|
||||
"module-details-from-path": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"react-hook-form": "7.62.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"recharts": "2.15.4",
|
||||
"require-in-the-middle": "8.0.1",
|
||||
"rss-parser": "3.13.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.33.5",
|
||||
|
||||
Reference in New Issue
Block a user