diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 85ccd34cc2..ac78619549 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -55,7 +55,7 @@ jobs: "danibarranqueroo" "HugoPBrito" "jfagoagas" - "josemazo" + "josema-xyz" "lydiavilchez" "mmuller88" "MrCloudSec" diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 8de1e9d9fa..4374ceea66 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -65,6 +65,10 @@ jobs: E2E_OCI_KEY_CONTENT: ${{ secrets.E2E_OCI_KEY_CONTENT }} E2E_OCI_REGION: ${{ secrets.E2E_OCI_REGION }} E2E_NEW_USER_PASSWORD: ${{ secrets.E2E_NEW_USER_PASSWORD }} + E2E_ALIBABACLOUD_ACCOUNT_ID: ${{ secrets.E2E_ALIBABACLOUD_ACCOUNT_ID }} + E2E_ALIBABACLOUD_ACCESS_KEY_ID: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_ID }} + E2E_ALIBABACLOUD_ACCESS_KEY_SECRET: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_SECRET }} + E2E_ALIBABACLOUD_ROLE_ARN: ${{ secrets.E2E_ALIBABACLOUD_ROLE_ARN }} # Pass E2E paths from impact analysis E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }} RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }} diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 4b404586a1..6f0cc8b484 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.20.0] (Prowler UNRELEASED) + +### 🔄 Changed + +- Attack Paths: Queries definition now has short description and attribution [(#9983)](https://github.com/prowler-cloud/prowler/pull/9983) +- Attack Paths: Internet node is created while scan [(#9992)](https://github.com/prowler-cloud/prowler/pull/9992) + +--- + +## [1.19.2] (Prowler v5.18.2) + +### 🐞 Fixed + +- SAML role mapping now prevents removing the last MANAGE_ACCOUNT user [(#10007)](https://github.com/prowler-cloud/prowler/pull/10007) + +--- + ## [1.19.0] (Prowler v5.18.0) ### 🚀 Added diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index 60208b7e51..610816a57b 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -1,17 +1,18 @@ from api.attack_paths.queries.types import ( + AttackPathsQueryAttribution, AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition, ) from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL -# Privilege Escalation Queries (based on pathfinding.cloud research) -# https://github.com/DataDog/pathfinding.cloud -# ------------------------------------------------------------------- +# Custom Attack Path Queries +# -------------------------- AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( id="aws-internet-exposed-ec2-sensitive-s3-access", - name="Identify internet-exposed EC2 instances with sensitive S3 access", + name="Internet-Exposed EC2 with Sensitive S3 Access", + short_description="Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets.", description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.", provider="aws", cypher=f""" @@ -35,8 +36,7 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( YIELD rel AS can_access UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -62,15 +62,15 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( AWS_RDS_INSTANCES = AttackPathsQueryDefinition( id="aws-rds-instances", - name="Identify provisioned RDS instances", + name="RDS Instances Inventory", + short_description="List all provisioned RDS database instances in the account.", description="List the selected AWS account alongside the RDS instances it owns.", provider="aws", cypher=f""" MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -79,7 +79,8 @@ AWS_RDS_INSTANCES = AttackPathsQueryDefinition( AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( id="aws-rds-unencrypted-storage", - name="Identify RDS instances without storage encryption", + name="Unencrypted RDS Instances", + short_description="Find RDS instances with storage encryption disabled.", description="Find RDS instances with storage encryption disabled within the selected account.", provider="aws", cypher=f""" @@ -87,8 +88,7 @@ AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( WHERE rds.storage_encrypted = false UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -97,7 +97,8 @@ AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( id="aws-s3-anonymous-access-buckets", - name="Identify S3 buckets with anonymous access", + name="S3 Buckets with Anonymous Access", + short_description="Find S3 buckets that allow anonymous access.", description="Find S3 buckets that allow anonymous access within the selected account.", provider="aws", cypher=f""" @@ -105,8 +106,7 @@ AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( WHERE s3.anonymous_access = true UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -115,7 +115,8 @@ AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( id="aws-iam-statements-allow-all-actions", - name="Identify IAM statements that allow all actions", + name="IAM Statements Allowing All Actions", + short_description="Find IAM policy statements that allow all actions via wildcard (*).", description="Find IAM policy statements that allow all actions via '*' within the selected account.", provider="aws", cypher=f""" @@ -124,8 +125,7 @@ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( AND any(x IN stmt.action WHERE x = '*') UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -134,7 +134,8 @@ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( id="aws-iam-statements-allow-delete-policy", - name="Identify IAM statements that allow iam:DeletePolicy", + name="IAM Statements Allowing Policy Deletion", + short_description="Find IAM policy statements that allow iam:DeletePolicy.", description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.", provider="aws", cypher=f""" @@ -143,8 +144,7 @@ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( AND any(x IN stmt.action WHERE x = "iam:DeletePolicy") UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -153,7 +153,8 @@ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( id="aws-iam-statements-allow-create-actions", - name="Identify IAM statements that allow create actions", + name="IAM Statements Allowing Create Actions", + short_description="Find IAM policy statements that allow any create action.", description="Find IAM policy statements that allow actions containing 'create' within the selected account.", provider="aws", cypher=f""" @@ -162,8 +163,7 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create") UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -176,7 +176,8 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( id="aws-ec2-instances-internet-exposed", - name="Identify internet-exposed EC2 instances", + name="Internet-Exposed EC2 Instances", + short_description="Find EC2 instances flagged as exposed to the internet.", description="Find EC2 instances flagged as exposed to the internet within the selected account.", provider="aws", cypher=f""" @@ -190,8 +191,7 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( YIELD rel AS can_access UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -200,7 +200,8 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( id="aws-security-groups-open-internet-facing", - name="Identify internet-facing resources with open security groups", + name="Open Security Groups on Internet-Facing Resources", + short_description="Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0.", description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.", provider="aws", cypher=f""" @@ -216,8 +217,7 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( YIELD rel AS can_access UNWIND nodes(path_ec2) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path_ec2, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -226,7 +226,8 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( id="aws-classic-elb-internet-exposed", - name="Identify internet-exposed Classic Load Balancers", + name="Internet-Exposed Classic Load Balancers", + short_description="Find Classic Load Balancers exposed to the internet with their listeners.", description="Find Classic Load Balancers exposed to the internet along with their listeners.", provider="aws", cypher=f""" @@ -240,8 +241,7 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( YIELD rel AS can_access UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -250,7 +250,8 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( id="aws-elbv2-internet-exposed", - name="Identify internet-exposed ELBv2 load balancers", + name="Internet-Exposed ALB/NLB Load Balancers", + short_description="Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners.", description="Find ELBv2 load balancers exposed to the internet along with their listeners.", provider="aws", cypher=f""" @@ -264,8 +265,7 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( YIELD rel AS can_access UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -274,7 +274,8 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( id="aws-public-ip-resource-lookup", - name="Identify resources by public IP address", + name="Resource Lookup by Public IP", + short_description="Find the AWS resource associated with a given public IP address.", description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.", provider="aws", cypher=f""" @@ -305,8 +306,7 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( YIELD rel AS can_access UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -320,38 +320,24 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( ], ) +# Privilege Escalation Queries (based on pathfinding.cloud research) +# https://github.com/DataDog/pathfinding.cloud +# ------------------------------------------------------------------- -AWS_IAM_PRIVESC_PASSROLE_EC2 = 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).", +# BEDROCK-001 +AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( + id="aws-bedrock-privesc-passrole-code-interpreter", + name="Bedrock Code Interpreter with Privileged Role (BEDROCK-001)", + short_description="Create a Bedrock AgentCore Code Interpreter with a privileged role attached.", + description="Detect principals who can pass IAM roles and create Bedrock AgentCore Code Interpreters. This allows creating a code interpreter with a privileged role attached, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BEDROCK-001 - iam:PassRole + bedrock-agentcore:CreateCodeInterpreter", + link="https://pathfinding.cloud/paths/bedrock-001", + ), provider="aws", cypher=f""" - // 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) + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) WHERE stmt_passrole.effect = 'Allow' AND any(action IN stmt_passrole.action WHERE toLower(action) = 'iam:passrole' @@ -359,8 +345,55 @@ AWS_IAM_PRIVESC_PASSROLE_EC2 = AttackPathsQueryDefinition( OR action = '*' ) - // Find statements granting ec2:RunInstances - MATCH path_ec2 = (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement) + // Find bedrock-agentcore:CreateCodeInterpreter permission + MATCH (principal)--(bedrock_policy:AWSPolicy)--(stmt_bedrock:AWSPolicyStatement) + WHERE stmt_bedrock.effect = 'Allow' + AND any(action IN stmt_bedrock.action WHERE + toLower(action) = 'bedrock-agentcore:createcodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find roles that trust Bedrock service (can be passed to Bedrock) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-001 +AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( + id="aws-ec2-privesc-passrole-iam", + name="EC2 Instance Launch with Privileged Role (EC2-001)", + short_description="Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS.", + 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.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-001 - iam:PassRole + ec2:RunInstances", + link="https://pathfinding.cloud/paths/ec2-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ec2:RunInstances permission + MATCH (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement) WHERE stmt_ec2.effect = 'Allow' AND any(action IN stmt_ec2.action WHERE toLower(action) = 'ec2:runinstances' @@ -369,149 +402,465 @@ AWS_IAM_PRIVESC_PASSROLE_EC2 = AttackPathsQueryDefinition( ) // 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 - ) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WHERE 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:{PROWLER_FINDING_LABEL}) - 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=[], -) - -# TODO: Add ProwlerFinding nodes -AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = 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, 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 role with elevated permissions - 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 before creating virtual nodes - WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role - - // Create virtual Glue endpoint node (one per unique principal->target pair) - CALL apoc.create.vNode(['GlueDevEndpoint'], { - name: 'New Dev Endpoint', - description: 'Glue endpoint with target role attached', - id: effective_principal.arn + '->' + target_role.arn - }) - YIELD node AS glue_endpoint - - CALL apoc.create.vRelationship(effective_principal, 'CREATES_ENDPOINT', { - permissions: ['iam:PassRole', 'glue:CreateDevEndpoint'], - technique: 'new-passrole' - }, glue_endpoint) - YIELD rel AS create_rel - - CALL apoc.create.vRelationship(glue_endpoint, 'RUNS_AS', {}, target_role) - YIELD rel AS runs_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 paths for visualization - MATCH path_principal = (aws)--(principal) - MATCH path_target = (aws)--(target_role) + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path_principal, path_target, - glue_endpoint, escalation_outcome, create_rel, runs_rel, grants_rel + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, parameters=[], ) -AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( - id="aws-iam-privesc-attach-role-policy-assume-role", - name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole", - description="Detect principals who can both attach policies to roles AND assume those roles. This two-step attack allows modifying a role's permissions then assuming it to gain elevated access. This is a principal-access escalation path (pathfinding.cloud: iam-014).", +# EC2-002 +AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition( + id="aws-ec2-privesc-modify-instance-attribute", + name="EC2 Role Hijacking via UserData Injection (EC2-002)", + short_description="Inject malicious scripts into EC2 instance userData to gain the attached role's permissions.", + description="Detect principals who can modify EC2 instance userData, stop, and start instances. This allows injecting malicious scripts that execute on instance restart, gaining the permissions of the instance's attached IAM role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-002 - ec2:ModifyInstanceAttribute + ec2:StopInstances + ec2:StartInstances", + link="https://pathfinding.cloud/paths/ec2-002", + ), provider="aws", cypher=f""" - // Create a virtual escalation outcome node (styled like a finding) - CALL apoc.create.vNode(['PrivilegeEscalation'], {{ - id: 'effective-administrator', - check_title: 'Privilege Escalation', - name: 'Effective Administrator', - status: 'FAIL', - severity: 'critical' - }}) - YIELD node AS admin_outcome + // Find principals with ec2:ModifyInstanceAttribute permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) + WHERE stmt_modify.effect = 'Allow' + AND any(action IN stmt_modify.action WHERE + toLower(action) = 'ec2:modifyinstanceattribute' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) - WITH admin_outcome + // Find ec2:StopInstances permission (can be same or different policy) + MATCH (principal)--(stop_policy:AWSPolicy)--(stmt_stop:AWSPolicyStatement) + WHERE stmt_stop.effect = 'Allow' + AND any(action IN stmt_stop.action WHERE + toLower(action) = 'ec2:stopinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) - // Find principals in the account - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal) + // Find ec2:StartInstances permission (can be same or different policy) + MATCH (principal)--(start_policy:AWSPolicy)--(stmt_start:AWSPolicyStatement) + WHERE stmt_start.effect = 'Allow' + AND any(action IN stmt_start.action WHERE + toLower(action) = 'ec2:startinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) - // Find statements granting iam:AttachRolePolicy - MATCH path_attach = (principal)--(attach_policy:AWSPolicy)--(stmt_attach:AWSPolicyStatement) + // Find EC2 instances with instance profiles (potential targets) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-003 +AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( + id="aws-ec2-privesc-passrole-spot-instances", + name="Spot Instance Launch with Privileged Role (EC2-003)", + short_description="Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS.", + description="Detect principals who can pass IAM roles and request EC2 Spot Instances. This allows launching a spot instance with a privileged role attached, gaining that role's permissions via the instance metadata service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-003 - iam:PassRole + ec2:RequestSpotInstances", + link="https://pathfinding.cloud/paths/ec2-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ec2:RequestSpotInstances permission + MATCH (principal)--(spot_policy:AWSPolicy)--(stmt_spot:AWSPolicyStatement) + WHERE stmt_spot.effect = 'Allow' + AND any(action IN stmt_spot.action WHERE + toLower(action) = 'ec2:requestspotinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find roles that trust EC2 service (can be passed to EC2 spot instances) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-004 +AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition( + id="aws-ec2-privesc-launch-template", + name="Launch Template Poisoning for Role Access (EC2-004)", + short_description="Inject malicious userData into launch templates that reference privileged roles, no PassRole needed.", + description="Detect principals who can create new launch template versions and modify launch templates. This allows injecting malicious user data into existing templates that already reference privileged IAM roles, without requiring iam:PassRole permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-004 - ec2:CreateLaunchTemplateVersion + ec2:ModifyLaunchTemplate", + link="https://pathfinding.cloud/paths/ec2-004", + ), + provider="aws", + cypher=f""" + // Find principals with ec2:CreateLaunchTemplateVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'ec2:createlaunchtemplateversion' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:ModifyLaunchTemplate permission + MATCH (principal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) + WHERE stmt_modify.effect = 'Allow' + AND any(action IN stmt_modify.action WHERE + toLower(action) = 'ec2:modifylaunchtemplate' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find launch templates in the account (potential targets) + MATCH path_target = (aws)--(template:LaunchTemplate) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-001 +AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-create-service", + name="ECS Service Creation with Privileged Role (ECS-001 - New Cluster)", + short_description="Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code.", + description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and create services. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container.", + provider="aws", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-001 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:CreateService", + link="https://pathfinding.cloud/paths/ecs-001", + ), + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:CreateCluster permission + MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) + WHERE stmt_cluster.effect = 'Allow' + AND any(action IN stmt_cluster.action WHERE + toLower(action) = 'ecs:createcluster' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:CreateService permission + MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) + WHERE stmt_service.effect = 'Allow' + AND any(action IN stmt_service.action WHERE + toLower(action) = 'ecs:createservice' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-002 +AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-run-task", + name="ECS Task Execution with Privileged Role (ECS-002 - New Cluster)", + short_description="Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code.", + description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and run tasks. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container. Unlike ecs:CreateService, ecs:RunTask executes the task once without creating a persistent service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-002 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:RunTask", + link="https://pathfinding.cloud/paths/ecs-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:CreateCluster permission + MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) + WHERE stmt_cluster.effect = 'Allow' + AND any(action IN stmt_cluster.action WHERE + toLower(action) = 'ecs:createcluster' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RunTask permission + MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) + WHERE stmt_runtask.effect = 'Allow' + AND any(action IN stmt_runtask.action WHERE + toLower(action) = 'ecs:runtask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-003 +AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-create-service-existing-cluster", + name="ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)", + short_description="Deploy a Fargate service with a privileged role on an existing ECS cluster.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and create services on existing clusters. Unlike ECS-001, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and launches it as a Fargate service, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-003 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:CreateService", + link="https://pathfinding.cloud/paths/ecs-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:CreateService permission + MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) + WHERE stmt_service.effect = 'Allow' + AND any(action IN stmt_service.action WHERE + toLower(action) = 'ecs:createservice' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-004 +AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-run-task-existing-cluster", + name="ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)", + short_description="Run a one-off Fargate task with a privileged role on an existing ECS cluster.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and run tasks on existing clusters. Unlike ECS-002, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and runs it as a one-off Fargate task, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-004 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:RunTask", + link="https://pathfinding.cloud/paths/ecs-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RunTask permission + MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) + WHERE stmt_runtask.effect = 'Allow' + AND any(action IN stmt_runtask.action WHERE + toLower(action) = 'ecs:runtask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-001 +AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-dev-endpoint", + name="Glue Dev Endpoint with Privileged Role (GLUE-001)", + short_description="Create a Glue development endpoint with a privileged role attached to gain its permissions.", + description="Detect principals who can pass IAM roles and create Glue development endpoints. This allows creating a dev endpoint with a privileged role attached, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-001 - iam:PassRole + glue:CreateDevEndpoint", + link="https://pathfinding.cloud/paths/glue-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 glue:CreateDevEndpoint permission + MATCH (principal)--(glue_policy:AWSPolicy)--(stmt_glue:AWSPolicyStatement) + WHERE stmt_glue.effect = 'Allow' + AND any(action IN stmt_glue.action WHERE + toLower(action) = 'glue:createdevendpoint' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-014 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy-assume-role", + name="Role Policy Attachment and Assumption (IAM-014)", + short_description="Attach policies to IAM roles and then assume them to gain elevated access.", + description="Detect principals who can both attach policies to roles AND assume those roles. This allows modifying a role's permissions then assuming it to gain elevated access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-014 - iam:AttachRolePolicy + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-014", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(attach_policy:AWSPolicy)--(stmt_attach:AWSPolicyStatement) WHERE stmt_attach.effect = 'Allow' AND any(action IN stmt_attach.action WHERE toLower(action) = 'iam:attachrolepolicy' @@ -519,8 +868,8 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( OR action = '*' ) - // Find statements granting sts:AssumeRole - MATCH path_assume = (principal)--(assume_policy:AWSPolicy)--(stmt_assume:AWSPolicyStatement) + // Find sts:AssumeRole permission + MATCH (principal)--(assume_policy:AWSPolicy)--(stmt_assume:AWSPolicyStatement) WHERE stmt_assume.effect = 'Allow' AND any(action IN stmt_assume.action WHERE toLower(action) = 'sts:assumerole' @@ -531,147 +880,26 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( // Find target roles that the principal can both modify AND assume MATCH path_target = (aws)--(target_role:AWSRole) WHERE target_role.arn CONTAINS $provider_uid - // Can attach policy to this role AND any(resource IN stmt_attach.resource WHERE resource = '*' OR target_role.arn CONTAINS resource OR resource CONTAINS target_role.name ) - // Can assume this role AND any(resource IN stmt_assume.resource WHERE resource = '*' OR target_role.arn CONTAINS resource OR resource CONTAINS target_role.name ) - // Deduplicate before creating virtual relationships - WITH DISTINCT admin_outcome, aws, principal, target_role - - // Create virtual relationships showing the attack path - CALL apoc.create.vRelationship(principal, 'CAN_MODIFY', {{ - via: 'iam:AttachRolePolicy' - }}, target_role) - YIELD rel AS modify_rel - - CALL apoc.create.vRelationship(target_role, 'LEADS_TO', {{ - technique: 'iam:AttachRolePolicy + sts:AssumeRole', - via: 'sts:AssumeRole', - reference: 'https://pathfinding.cloud/paths/iam-014' - }}, admin_outcome) - YIELD rel AS escalation_rel - - // Re-match paths for visualization - MATCH path_principal = (aws)--(principal) - MATCH path_target = (aws)--(target_role) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL}) - WHERE pf.status = 'FAIL' + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) RETURN path_principal, path_target, - admin_outcome, modify_rel, escalation_rel, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, parameters=[], ) -# TODO: Add ProwlerFinding nodes -AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( - id="aws-bedrock-privesc-passrole-code-interpreter", - name="Privilege Escalation: Bedrock Code Interpreter with PassRole", - description="Detect principals that can escalate privileges by passing a role to a Bedrock AgentCore Code Interpreter. The attacker creates a code interpreter with an arbitrary role, then invokes it to execute code with those credentials.", - provider="aws", - cypher=""" - CALL apoc.create.vNode(['PrivilegeEscalation'], { - id: 'effective-administrator-bedrock', - check_title: 'Privilege Escalation', - name: 'Effective Administrator (Bedrock)', - 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 Bedrock AgentCore permissions - MATCH (effective_principal)--(bedrock_policy:AWSPolicy)--(bedrock_stmt:AWSPolicyStatement) - WHERE bedrock_stmt.effect = 'Allow' - AND ( - any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:createcodeinterpreter' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*') - ) - AND ( - any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:startsession' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*') - ) - AND ( - any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:invoke' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*') - ) - - // 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 Bedrock node per principal - CALL apoc.create.vNode(['BedrockCodeInterpreter'], { - name: 'New Code Interpreter', - description: toString(target_count) + ' admin role(s) can be passed', - id: principal.arn, - target_role_count: target_count - }) - YIELD node AS bedrock_agent - - // Connect from principal (not effective_principal) to keep graph connected - CALL apoc.create.vRelationship(principal, 'CREATES_INTERPRETER', { - permissions: ['iam:PassRole', 'bedrock-agentcore:CreateCodeInterpreter', 'bedrock-agentcore:StartSession', 'bedrock-agentcore:Invoke'], - technique: 'new-passrole' - }, bedrock_agent) - 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(bedrock_agent, 'PASSES_ROLE', {}, target_role) - YIELD rel AS pass_rel - - CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', { - reference: 'https://pathfinding.cloud/paths/bedrock-001' - }, escalation_outcome) - YIELD rel AS grants_rel - - // Re-match path for visualization - MATCH path_principal = (aws)--(principal) - - RETURN path_principal, - bedrock_agent, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count - """, - parameters=[], -) - - # AWS Queries List # ---------------- @@ -688,8 +916,15 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [ AWS_CLASSIC_ELB_INTERNET_EXPOSED, AWS_ELBV2_INTERNET_EXPOSED, AWS_PUBLIC_IP_RESOURCE_LOOKUP, - AWS_IAM_PRIVESC_PASSROLE_EC2, + AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER, + AWS_EC2_PRIVESC_PASSROLE_IAM, + AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE, + AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES, + AWS_EC2_PRIVESC_LAUNCH_TEMPLATE, + AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE, + AWS_ECS_PRIVESC_PASSROLE_RUN_TASK, + AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER, AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT, AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE, - AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER, ] diff --git a/api/src/backend/api/attack_paths/queries/types.py b/api/src/backend/api/attack_paths/queries/types.py index d798dbbcdb..3a70805cd7 100644 --- a/api/src/backend/api/attack_paths/queries/types.py +++ b/api/src/backend/api/attack_paths/queries/types.py @@ -1,6 +1,14 @@ from dataclasses import dataclass, field +@dataclass +class AttackPathsQueryAttribution: + """Source attribution for an Attack Path query.""" + + text: str + link: str + + @dataclass class AttackPathsQueryParameterDefinition: """ @@ -23,7 +31,9 @@ class AttackPathsQueryDefinition: id: str name: str + short_description: str description: str provider: str cypher: str + attribution: AttackPathsQueryAttribution | None = None parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 778233ddab..fd2b223508 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -616,7 +616,7 @@ paths: operationId: attack_paths_scans_queries_retrieve description: Retrieve the catalog of Attack Paths queries available for this Attack Paths scan. - summary: List attack paths queries + summary: List Attack Paths queries parameters: - in: query name: fields[attack-paths-scans] @@ -714,7 +714,7 @@ paths: description: Bad request (e.g., Unknown Attack Paths query for the selected provider) '404': - description: No attack paths found for the given query and parameters + description: No Attack Paths found for the given query and parameters '500': description: Attack Paths query execution failed due to a database error /api/v1/compliance-overviews: @@ -12438,6 +12438,8 @@ components: type: string name: type: string + short_description: + type: string description: type: string provider: @@ -12446,12 +12448,42 @@ components: type: array items: $ref: '#/components/schemas/AttackPathsQueryParameter' + attribution: + allOf: + - $ref: '#/components/schemas/AttackPathsQueryAttribution' + nullable: true required: - id - name + - short_description - description - provider - parameters + AttackPathsQueryAttribution: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-attributions + id: {} + attributes: + type: object + properties: + text: + type: string + link: + type: string + required: + - text + - link AttackPathsQueryParameter: type: object required: diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py index 2c4e1484f8..4208107710 100644 --- a/api/src/backend/api/tests/test_attack_paths.py +++ b/api/src/backend/api/tests/test_attack_paths.py @@ -83,6 +83,7 @@ def test_execute_attack_paths_query_serializes_graph( definition = attack_paths_query_definition_factory( id="aws-rds", name="RDS", + short_description="Short desc", description="", cypher="MATCH (n) RETURN n", parameters=[], @@ -143,6 +144,7 @@ def test_execute_attack_paths_query_wraps_graph_errors( definition = attack_paths_query_definition_factory( id="aws-rds", name="RDS", + short_description="Short desc", description="", cypher="MATCH (n) RETURN n", parameters=[], diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 4bb7509bc7..c9f2746cfe 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3830,6 +3830,7 @@ class TestAttackPathsScanViewSet: AttackPathsQueryDefinition( id="aws-rds", name="RDS inventory", + short_description="List account RDS assets.", description="List account RDS assets", provider=provider.provider, cypher="MATCH (n) RETURN n", @@ -3892,6 +3893,7 @@ class TestAttackPathsScanViewSet: query_definition = AttackPathsQueryDefinition( id="aws-rds", name="RDS inventory", + short_description="List account RDS assets.", description="List account RDS assets", provider=provider.provider, cypher="MATCH (n) RETURN n", @@ -4049,6 +4051,7 @@ class TestAttackPathsScanViewSet: query_definition = AttackPathsQueryDefinition( id="aws-empty", name="empty", + short_description="", description="", provider=provider.provider, cypher="MATCH (n) RETURN n", @@ -10841,25 +10844,20 @@ class TestTenantFinishACSView: assert "sso_saml_failed=true" in response.url def test_dispatch_skips_role_mapping_when_single_manage_account_user( - self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + saml_setup, + settings, + monkeypatch, ): """Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role""" monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") user = create_test_user tenant = tenants_fixture[0] - # Create a single role with manage_account=True for the user - admin_role = Role.objects.using(MainRouter.admin_db).create( - name="admin", - tenant=tenant, - manage_account=True, - manage_users=True, - manage_billing=True, - manage_providers=True, - manage_integrations=True, - manage_scans=True, - unlimited_visibility=True, - ) + admin_role = admin_role_fixture UserRoleRelationship.objects.using(MainRouter.admin_db).create( user=user, role=admin_role, tenant_id=tenant.id ) @@ -10930,35 +10928,26 @@ class TestTenantFinishACSView: .exists() ) - def test_dispatch_applies_role_mapping_when_multiple_manage_account_users( - self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch + def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_existing_role( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + roles_fixture, + saml_setup, + settings, + monkeypatch, ): - """Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role""" + """Test that role mapping is skipped when it would remove the last MANAGE_ACCOUNT user""" monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") user = create_test_user tenant = tenants_fixture[0] - # Create a second user with manage_account=True - second_admin = User.objects.using(MainRouter.admin_db).create( - email="admin2@prowler.com", name="Second Admin" - ) - admin_role = Role.objects.using(MainRouter.admin_db).create( - name="admin", - tenant=tenant, - manage_account=True, - manage_users=True, - manage_billing=True, - manage_providers=True, - manage_integrations=True, - manage_scans=True, - unlimited_visibility=True, - ) + admin_role = admin_role_fixture + viewer_role = roles_fixture[3] UserRoleRelationship.objects.using(MainRouter.admin_db).create( user=user, role=admin_role, tenant_id=tenant.id ) - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=second_admin, role=admin_role, tenant_id=tenant.id - ) social_account = SocialAccount( user=user, @@ -10967,7 +10956,7 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["viewer"], # This SHOULD be applied + "userType": [viewer_role.name], }, ) @@ -11005,10 +10994,91 @@ class TestTenantFinishACSView: assert response.status_code == 302 - # Verify the viewer role was created and assigned (role mapping was applied) - viewer_role = Role.objects.using(MainRouter.admin_db).get( - name="viewer", tenant=tenant + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, role=admin_role, tenant_id=tenant.id) + .exists() ) + assert not ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, role=viewer_role, tenant_id=tenant.id) + .exists() + ) + + def test_dispatch_applies_role_mapping_when_multiple_manage_account_users( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + roles_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + + # Create a second user with manage_account=True + second_admin = User.objects.using(MainRouter.admin_db).create( + email="admin2@prowler.com", name="Second Admin" + ) + admin_role = admin_role_fixture + viewer_role = roles_fixture[3] + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, role=admin_role, tenant_id=tenant.id + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=second_admin, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": [viewer_role.name], # This SHOULD be applied + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + # Verify the viewer role was assigned (role mapping was applied) assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) .filter(user=user, role=viewer_role, tenant_id=tenant.id) @@ -11022,6 +11092,86 @@ class TestTenantFinishACSView: .exists() ) + def test_dispatch_applies_role_mapping_for_non_admin_user_with_single_admin( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + roles_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that role mapping is applied for a non-admin user when a single admin exists""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + admin_user = create_test_user + tenant = tenants_fixture[0] + non_admin_user = User.objects.using(MainRouter.admin_db).create( + email="viewer@prowler.com", name="Viewer" + ) + + admin_role = admin_role_fixture + viewer_role = roles_fixture[3] + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=admin_user, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=non_admin_user, + provider="saml", + extra_data={ + "firstName": ["Jane"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": [viewer_role.name], + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = non_admin_user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = non_admin_user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=non_admin_user, role=viewer_role, tenant_id=tenant.id) + .exists() + ) + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=admin_user, role=admin_role, tenant_id=tenant.id) + .exists() + ) + @pytest.mark.django_db class TestLighthouseConfigViewSet: diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index e2ae7fc8d2..6c42d83aa5 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1176,6 +1176,14 @@ class AttackPathsScanSerializer(RLSSerializer): return provider.uid if provider else None +class AttackPathsQueryAttributionSerializer(BaseSerializerV1): + text = serializers.CharField() + link = serializers.CharField() + + class JSONAPIMeta: + resource_name = "attack-paths-query-attributions" + + class AttackPathsQueryParameterSerializer(BaseSerializerV1): name = serializers.CharField() label = serializers.CharField() @@ -1190,7 +1198,9 @@ class AttackPathsQueryParameterSerializer(BaseSerializerV1): class AttackPathsQuerySerializer(BaseSerializerV1): id = serializers.CharField() name = serializers.CharField() + short_description = serializers.CharField() description = serializers.CharField() + attribution = AttackPathsQueryAttributionSerializer(allow_null=True, required=False) provider = serializers.CharField() parameters = AttackPathsQueryParameterSerializer(many=True) diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index d807dfd8fe..5c4791a756 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -763,27 +763,40 @@ class TenantFinishACSView(FinishACSView): .tenant ) - # Check if tenant has only one user with MANAGE_ACCOUNT role - users_with_manage_account = ( + role_name = ( + extra.get("userType", ["no_permissions"])[0].strip() + if extra.get("userType") + else "no_permissions" + ) + role = ( + Role.objects.using(MainRouter.admin_db) + .filter(name=role_name, tenant=tenant) + .first() + ) + + # Only skip mapping if it would remove the last MANAGE_ACCOUNT user + remaining_manage_account_users = ( UserRoleRelationship.objects.using(MainRouter.admin_db) .filter(role__manage_account=True, tenant_id=tenant.id) + .exclude(user_id=user_id) .values("user") .distinct() .count() ) + user_has_manage_account = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id) + .exists() + ) + role_manage_account = role.manage_account if role else False + would_remove_last_manage_account = ( + user_has_manage_account + and remaining_manage_account_users == 0 + and not role_manage_account + ) - # Only apply role mapping from userType if tenant does NOT have exactly one user with MANAGE_ACCOUNT - if users_with_manage_account != 1: - role_name = ( - extra.get("userType", ["no_permissions"])[0].strip() - if extra.get("userType") - else "no_permissions" - ) - try: - role = Role.objects.using(MainRouter.admin_db).get( - name=role_name, tenant=tenant - ) - except Role.DoesNotExist: + if not would_remove_last_manage_account: + if role is None: role = Role.objects.using(MainRouter.admin_db).create( name=role_name, tenant=tenant, diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index e50f92e1ab..00617017bb 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -1663,6 +1663,7 @@ def attack_paths_query_definition_factory(): definition_payload = { "id": "aws-test", "name": "Attack Paths Test Query", + "short_description": "Synthetic short description for tests.", "description": "Synthetic Attack Paths definition for tests.", "provider": "aws", "cypher": "RETURN 1", diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py index d39e6fa81f..af8094e172 100644 --- a/api/src/backend/tasks/jobs/attack_paths/config.py +++ b/api/src/backend/tasks/jobs/attack_paths/config.py @@ -12,8 +12,10 @@ BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000) # Neo4j internal labels (Prowler-specific, not provider-specific) # - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources. # - `ProviderResource`: Added to ALL synced nodes for provider isolation and drop/query ops. +# - `Internet`: Singleton node representing external internet access for exposed-resource queries. PROWLER_FINDING_LABEL = "ProwlerFinding" PROVIDER_RESOURCE_LABEL = "ProviderResource" +INTERNET_NODE_LABEL = "Internet" @dataclass(frozen=True) diff --git a/api/src/backend/tasks/jobs/attack_paths/indexes.py b/api/src/backend/tasks/jobs/attack_paths/indexes.py index 708e9c7f84..9ccd8cab04 100644 --- a/api/src/backend/tasks/jobs/attack_paths/indexes.py +++ b/api/src/backend/tasks/jobs/attack_paths/indexes.py @@ -6,6 +6,7 @@ from cartography.client.core.tx import run_write_query from celery.utils.log import get_task_logger from tasks.jobs.attack_paths.config import ( + INTERNET_NODE_LABEL, PROWLER_FINDING_LABEL, PROVIDER_RESOURCE_LABEL, ) @@ -30,6 +31,8 @@ FINDINGS_INDEX_STATEMENTS = [ f"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.provider_uid);", f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);", f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);", + # Internet node index for MERGE lookups + f"CREATE INDEX internet_id IF NOT EXISTS FOR (n:{INTERNET_NODE_LABEL}) ON (n.id);", ] # Indexes for provider resource sync operations diff --git a/api/src/backend/tasks/jobs/attack_paths/internet.py b/api/src/backend/tasks/jobs/attack_paths/internet.py new file mode 100644 index 0000000000..83517bc903 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/internet.py @@ -0,0 +1,67 @@ +""" +Internet node enrichment for Attack Paths graph. + +Creates a real Internet node and CAN_ACCESS relationships to +internet-exposed resources (EC2Instance, LoadBalancer, LoadBalancerV2) +in the temporary scan database before sync. +""" + +import neo4j + +from cartography.config import Config as CartographyConfig +from celery.utils.log import get_task_logger + +from api.models import Provider +from prowler.config import config as ProwlerConfig +from tasks.jobs.attack_paths.config import get_root_node_label +from tasks.jobs.attack_paths.queries import ( + CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE, + CREATE_INTERNET_NODE, + render_cypher_template, +) + +logger = get_task_logger(__name__) + + +def analysis( + neo4j_session: neo4j.Session, + prowler_api_provider: Provider, + config: CartographyConfig, +) -> int: + """ + Create Internet node and CAN_ACCESS relationships to exposed resources. + + Args: + neo4j_session: Active Neo4j session (temp database). + prowler_api_provider: The Prowler API provider instance. + config: Cartography configuration with update_tag. + + Returns: + Number of CAN_ACCESS relationships created. + """ + provider_uid = str(prowler_api_provider.uid) + + parameters = { + "provider_uid": provider_uid, + "last_updated": config.update_tag, + "prowler_version": ProwlerConfig.prowler_version, + } + + logger.info(f"Creating Internet node for provider {provider_uid}") + neo4j_session.run(CREATE_INTERNET_NODE, parameters) + + query = render_cypher_template( + CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE, + {"__ROOT_LABEL__": get_root_node_label(prowler_api_provider.provider)}, + ) + + logger.info( + f"Creating CAN_ACCESS relationships from Internet to exposed resources for {provider_uid}" + ) + result = neo4j_session.run(query, parameters) + relationships_merged = result.single().get("relationships_merged", 0) + + logger.info( + f"Created {relationships_merged} CAN_ACCESS relationships for provider {provider_uid}" + ) + return relationships_merged diff --git a/api/src/backend/tasks/jobs/attack_paths/queries.py b/api/src/backend/tasks/jobs/attack_paths/queries.py index bf935cc9ca..75ef9ec5b3 100644 --- a/api/src/backend/tasks/jobs/attack_paths/queries.py +++ b/api/src/backend/tasks/jobs/attack_paths/queries.py @@ -1,5 +1,6 @@ # Cypher query templates for Attack Paths operations from tasks.jobs.attack_paths.config import ( + INTERNET_NODE_LABEL, PROWLER_FINDING_LABEL, PROVIDER_RESOURCE_LABEL, ) @@ -91,6 +92,37 @@ CLEANUP_FINDINGS_TEMPLATE = f""" RETURN COUNT(finding) AS deleted_findings_count """ +# Internet queries (used by internet.py) +# --------------------------------------- + +CREATE_INTERNET_NODE = f""" + MERGE (internet:{INTERNET_NODE_LABEL} {{id: 'Internet'}}) + ON CREATE SET + internet.name = 'Internet', + internet.firstseen = timestamp(), + internet.lastupdated = $last_updated, + internet._module_name = 'cartography:prowler', + internet._module_version = $prowler_version + ON MATCH SET + internet.lastupdated = $last_updated +""" + +CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE = f""" + MATCH (account:__ROOT_LABEL__ {{id: $provider_uid}})-->(resource) + WHERE resource.exposed_internet = true + WITH resource + MATCH (internet:{INTERNET_NODE_LABEL} {{id: 'Internet'}}) + MERGE (internet)-[r:CAN_ACCESS]->(resource) + ON CREATE SET + r.firstseen = timestamp(), + r.lastupdated = $last_updated, + r._module_name = 'cartography:prowler', + r._module_version = $prowler_version + ON MATCH SET + r.lastupdated = $last_updated + RETURN COUNT(r) AS relationships_merged +""" + # Sync queries (used by sync.py) # ------------------------------- diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py index 1ffcbf55e7..faf6c4e176 100644 --- a/api/src/backend/tasks/jobs/attack_paths/scan.py +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -16,7 +16,7 @@ from api.models import ( StateChoices, ) from api.utils import initialize_prowler_provider -from tasks.jobs.attack_paths import db_utils, findings, sync, utils +from tasks.jobs.attack_paths import db_utils, findings, internet, sync, utils from tasks.jobs.attack_paths.config import get_cartography_ingestion_function # Without this Celery goes crazy with Cartography logging @@ -135,7 +135,15 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: cartography_analysis.run(tmp_neo4j_session, tmp_cartography_config) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96) - # Adding Prowler nodes and relationships + # Creating Internet node and CAN_ACCESS relationships + logger.info( + f"Creating Internet graph for AWS account {prowler_api_provider.uid}" + ) + internet.analysis( + tmp_neo4j_session, prowler_api_provider, tmp_cartography_config + ) + + # Adding Prowler Finding nodes and relationships logger.info( f"Syncing Prowler analysis for AWS account {prowler_api_provider.uid}" ) diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index ebee87c981..dbedc6ae7a 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call, patch import pytest from tasks.jobs.attack_paths import findings as findings_module +from tasks.jobs.attack_paths import internet as internet_module from tasks.jobs.attack_paths.scan import run as attack_paths_run from api.models import ( @@ -37,6 +38,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") @patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.findings.analysis") @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @@ -67,6 +69,7 @@ class TestAttackPathsRun: mock_cartography_ontology, mock_findings_indexes, mock_findings_analysis, + mock_internet_analysis, mock_sync_indexes, mock_drop_subgraph, mock_sync, @@ -139,6 +142,7 @@ class TestAttackPathsRun: # These use tmp_cartography_config (neo4j_database="db-scan-id") mock_cartography_analysis.assert_called_once() mock_cartography_ontology.assert_called_once() + mock_internet_analysis.assert_called_once() mock_findings_analysis.assert_called_once() mock_drop_subgraph.assert_called_once_with( database="tenant-db", @@ -207,6 +211,7 @@ class TestAttackPathsRun: patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"), patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"), patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes"), + patch("tasks.jobs.attack_paths.scan.internet.analysis"), patch("tasks.jobs.attack_paths.scan.findings.analysis"), patch( "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan", @@ -757,3 +762,45 @@ class TestAttackPathsFindingsHelpers: findings_module.load_findings(mock_session, empty_gen(), provider, config) mock_session.run.assert_not_called() + + +class TestInternetAnalysis: + def _make_provider_and_config(self): + provider = MagicMock() + provider.provider = "aws" + provider.uid = "123456789012" + config = SimpleNamespace(update_tag=1234567890) + return provider, config + + def test_analysis_creates_node_and_relationships(self): + """Verify both Cypher statements are executed and relationship count returned.""" + mock_session = MagicMock() + mock_result = MagicMock() + mock_result.single.return_value = {"relationships_merged": 3} + mock_session.run.side_effect = [None, mock_result] + provider, config = self._make_provider_and_config() + + with patch( + "tasks.jobs.attack_paths.internet.get_root_node_label", + return_value="AWSAccount", + ): + result = internet_module.analysis(mock_session, provider, config) + + assert mock_session.run.call_count == 2 + assert result == 3 + + def test_analysis_zero_exposed_resources(self): + """When no resources are exposed, zero relationships are created.""" + mock_session = MagicMock() + mock_result = MagicMock() + mock_result.single.return_value = {"relationships_merged": 0} + mock_session.run.side_effect = [None, mock_result] + provider, config = self._make_provider_and_config() + + with patch( + "tasks.jobs.attack_paths.internet.get_root_node_label", + return_value="AWSAccount", + ): + result = internet_module.analysis(mock_session, provider, config) + + assert result == 0 diff --git a/contrib/k8s/helm/prowler-app/.helmignore b/contrib/k8s/helm/prowler-app/.helmignore new file mode 100644 index 0000000000..7d250c5aa5 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/.helmignore @@ -0,0 +1,24 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +examples +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/contrib/k8s/helm/prowler-app/Chart.lock b/contrib/k8s/helm/prowler-app/Chart.lock new file mode 100644 index 0000000000..fe4af2f9e2 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/Chart.lock @@ -0,0 +1,12 @@ +dependencies: +- name: postgresql + repository: oci://registry-1.docker.io/bitnamicharts + version: 18.2.0 +- name: valkey + repository: https://valkey.io/valkey-helm/ + version: 0.9.3 +- name: neo4j + repository: https://helm.neo4j.com/neo4j + version: 2025.12.1 +digest: sha256:da19233c6832727345fcdb314d683d30aa347d349f270023f3a67149bffb009b +generated: "2026-01-26T12:00:06.798702+02:00" diff --git a/contrib/k8s/helm/prowler-app/Chart.yaml b/contrib/k8s/helm/prowler-app/Chart.yaml new file mode 100644 index 0000000000..5397d43569 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/Chart.yaml @@ -0,0 +1,33 @@ +apiVersion: v2 +name: prowler +description: Prowler is an Open Cloud Security tool for AWS, Azure, GCP and Kubernetes. It helps for continuous monitoring, security assessments and audits, incident response, compliance, hardening and forensics readiness. +type: application +version: 0.0.1 +appVersion: "5.17.0" +home: https://prowler.com +icon: https://cdn.prod.website-files.com/68c4ec3f9fb7b154fbcb6e36/68c5e0fea5d0059b9e05834b_Link.png +keywords: + - security + - aws + - azure + - gcp + - kubernetes +maintainers: + - name: Mihai + email: mihai.legat@gmail.com +dependencies: + # https://artifacthub.io/packages/helm/bitnami/postgresql + - name: postgresql + version: 18.2.0 + repository: oci://registry-1.docker.io/bitnamicharts + condition: postgresql.enabled + # https://valkey.io/valkey-helm/ + - name: valkey + version: 0.9.3 + repository: https://valkey.io/valkey-helm/ + condition: valkey.enabled + # https://helm.neo4j.com/neo4j + - name: neo4j + version: 2025.12.1 + repository: https://helm.neo4j.com/neo4j + condition: neo4j.enabled diff --git a/contrib/k8s/helm/prowler-app/README.md b/contrib/k8s/helm/prowler-app/README.md new file mode 100644 index 0000000000..544b5f39a7 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/README.md @@ -0,0 +1,143 @@ + + +# Prowler App Helm Chart + +![Version: 0.0.1](https://img.shields.io/badge/Version-0.0.1-informational?style=flat-square) +![AppVersion: 5.17.0](https://img.shields.io/badge/AppVersion-5.17.0-informational?style=flat-square) + +Prowler is an Open Cloud Security tool for AWS, Azure, GCP and Kubernetes. It helps for continuous monitoring, security assessments and audits, incident response, compliance, hardening and forensics readiness. Includes CIS, NIST 800, NIST CSF, CISA, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, Well-Architected Security, ENS and more. + +## Architecture + +The Prowler App consists of three main components: + +- **Prowler UI**: A user-friendly web interface for running Prowler and viewing results, powered by Next.js. +- **Prowler API**: The backend API that executes Prowler scans and stores the results, built with Django REST Framework. +- **Prowler SDK**: A Python SDK that integrates with the Prowler CLI for advanced functionality. + +The app leverages the following supporting infrastructure: + +- **PostgreSQL**: Used for persistent storage of scan results. +- **Celery Workers**: Facilitate asynchronous execution of Prowler scans. +- **Valkey**: An in-memory database serving as a message broker for the Celery workers. +- **Neo4j**: Graph Database +- **Keda**: Kubernetes Event-driven Autoscaling (Keda) automatically scales the number of Celery worker pods based on the workload, ensuring efficient resource utilization and responsiveness. + +## Setup + +This guide walks you through installing Prowler App using Helm. For a minimal installation example, see the [minimal installation example](./examples/minimal-installation/). + +### Prerequisites + +- Kubernetes cluster (1.24+) +- Helm 3.x installed +- `kubectl` configured to access your cluster +- Access to the Prowler Helm chart repository (or local chart) + +### Step 1: Create Required Secrets + +Before installing the Helm chart, you must create a Kubernetes Secret containing the required authentication keys and secrets. + +1. **Generate the required keys and secrets:** + + ```bash + # Generate Django token signing key (private key) + openssl genrsa -out private.pem 2048 + + # Generate Django token verifying key (public key) + openssl rsa -in private.pem -pubout -out public.pem + + # Generate Django secrets encryption key + openssl rand -base64 32 + + # Generate Auth secret + openssl rand -base64 32 + ``` + +2. **Create the secret file:** + + Create a file named `secrets.yaml` with the following structure: + + ```yaml + apiVersion: v1 + kind: Secret + type: Opaque + metadata: + name: prowler-secret + stringData: + DJANGO_TOKEN_SIGNING_KEY: | + -----BEGIN PRIVATE KEY----- + [paste your private key here] + -----END PRIVATE KEY----- + + DJANGO_TOKEN_VERIFYING_KEY: | + -----BEGIN PUBLIC KEY----- + [paste your public key here] + -----END PUBLIC KEY----- + + DJANGO_SECRETS_ENCRYPTION_KEY: "[paste your encryption key here]" + + AUTH_SECRET: "[paste your auth secret here]" + + NEO4J_PASSWORD: "[prowler-password]" + NEO4J_AUTH: "neo4j/[prowler-password]" + ``` + + > **Note:** You can use the [example secrets file](./examples/minimal-installation/secrets.yaml) as a template, but **always replace the placeholder values with your own secure keys** before applying. + +3. **Apply the secret to your cluster:** + + ```bash + kubectl apply -f secrets.yaml + ``` + +### Step 2: Configure Values + +Create a `values.yaml` file to customize your installation. At minimum, you need to configure the UI access method. + +**Option A: Using Ingress (Recommended for production)** + +```yaml +ui: + ingress: + enabled: true + hosts: + - host: prowler.example.com + paths: + - path: / + pathType: ImplementationSpecific +``` + +**Option B: Using authUrl (For proxy setups)** + +```yaml +ui: + authUrl: prowler.example.com +``` + +> **Note:** See the [minimal installation example](./examples/minimal-installation/values.yaml) for a complete reference. + +### Step 3: Install the Chart + +Install Prowler App using Helm: + +```bash +helm dependency update +helm install prowler prowler/prowler-app -f values.yaml +``` + +### Using Existing PostgreSQL and Valkey Instances + +By default, this Chart uses Bitnami's Charts to deploy [PostgreSQL](https://artifacthub.io/packages/helm/bitnami/postgresql), [Neo4j](https://helm.neo4j.com/neo4j) and [Valkey official helm chart](https://valkey.io/valkey-helm/). **Note:** This default setup is not production-ready. + +To connect to existing PostgreSQL, Neo4j and Valkey instances: + +1. Create a `Secret` containing the correct database and message broker credentials +2. Reference the secret in the [values.yaml](values.yaml) file api->secrets list + +## Contributing + +Feel free to contact the maintainer of this repository for any questions or concerns. Contributions are encouraged and appreciated. diff --git a/contrib/k8s/helm/prowler-app/examples/minimal-installation/README.md b/contrib/k8s/helm/prowler-app/examples/minimal-installation/README.md new file mode 100644 index 0000000000..e22f429b44 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/examples/minimal-installation/README.md @@ -0,0 +1,46 @@ +# Minimal Installation Example + +This example demonstrates a minimal installation of Prowler in a Kubernetes cluster. + +## Installation + +To install Prowler using this example: + +1. First, create the required secret: +```bash +# Edit secret.yaml and set secure values before applying +kubectl apply -f secret.yaml +``` + +1. Install the chart using the base values file: +```bash +# Basic installation +helm install prowler prowler/prowler-app -f values.yaml +``` + +## Configuration + +The example contains the following configuration files: + +### `secret.yaml` +Contains all required secrets for the Prowler installation. **Must be applied before installing the Helm chart**. Make sure to replace all placeholder values with secure values before applying. + +### `values.yaml` +```yaml +ui: + # Note: You should set either `authUrl` if you use prowler behind a proxy or enable `ingress`. + + # Example with authUrl: + # authUrl: example.prowler.com + + # Example with ingress: + ingress: + enabled: true + hosts: + - host: example.prowler.com + paths: + - path: / + pathType: ImplementationSpecific +``` + +Make sure to adjust the hostname in the values file to match your environment before installing. diff --git a/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml b/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml new file mode 100644 index 0000000000..2e379ef5c8 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml @@ -0,0 +1,58 @@ +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: prowler-secret +stringData: + # openssl genrsa -out private.pem 2048 + DJANGO_TOKEN_SIGNING_KEY: | + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCIro0QiLAxw7rF + GO0NgAWJfkpYE5ysMGDCbId07HUrv+/SCoRjqKVzGJVIvmNP5oByzSehPgswW9v3 + 3dqe2r9sCS1JyMa+XO3qfZCR0uRDcPCwZjIyr0QQLpWAymdBa8baeHsU1/3Orjcb + Vrr+lNx4HQJOiSn094iXPReW/25hYeq/SXs79V2CR87PGdoZAhb8IllAxJgdfkeB + /iWohY/1vfRTmIuMweWGXk0aKzPsBdvE/DqG4HjiNVEPh18G3vid0YTZNmm7u8vO + Cue3x9NQWGHA4QtxNtLtxlHcOEryqZ9ChO2nC+ew0Xl/v706XFNyLFicjisIKNQo + qdkaMS33AgMBAAECggEAGdJIChCYoL4mYafk2MEPyrrWFq+V0J3PGcvhB0DInfxD + tT2RZzZsE0NYqIZ3Qpf8OjPxwa9z863W74u1Cn+u3B0bti29BieONteD4VijEO6c + OecEorijth7m1Y7nVN+kkI9kSTrI0yvsczi+WOwMfpCUZ/vXtlSxNEkxVLBqzPCo + 9VxAFIjgWOj2rpw8nxPedves36PUrC5ghLqrOTe1jmw/Di0++47AXG+DsTXc00sc + 5+oybopm3Kimsxrqbf9s8SZf2A8NiwqcbLj8OtP2j2g4TCEgZYLD5Zmt+JN/wN4B + WsQG/Hwp4KPPm9QTHEpuuoPFP1CZWZeq8gPcV4apYQKBgQC+TuXjJCYhZqNIttTZ + z/i3hkKUEKQLkzTZnXaDzL5wHyEMVqM2E/WkilO0C9ZZwh0ENPzkp+JsHf7LEhHy + wSHOti81VzUCjN/YpCBKlOlClqSiDlOonImrobLei8xgvmA0VmGtirCXZyyzZUoV + OyPr17WpK6G/M5piX59MvKQg0QKBgQC33NBoQFD8A6FjrTopYmWfK099k9uQh9NE + bvUYsNAPunSDslmc/0PPHQC7fRX5Ime2BinXAN1PYtB/Fsu3jv/+FCUM5hVil0Dd + KBvt13+RYSCJKlhcGP1EkWoIg1F2XXBOZKJrC8VQ+Vyl2t06UcWQqy5M9J4VZaqI + fruOLU/URwKBgE55GjJfZZnASPRi78IhD94dbra/ZeWf/dr+IzCV7LEvJOGBmCtk + b5Y5s+o6N1krwetKLj3bPHJ4q+fwu5XuLZKfbTgBjcpPbL5YbzhRzx22IIzye2y7 + n8k2FBvQaaY62lC6jeyRk9/am4Qd8D5w9I77k9z+MOQ20yJda8KoxsUBAoGBAIQ9 + 5QPmppjsf4ry0C9t30uhWhYnX7fPiYviBpVQrwVxBVan076Q9xOjd6BicohzT4bj + XfqPW546o12VZsbKqqLzmEZzwpPb2EJ5E8V4xv8ojb86Xr03GArWUB55XQE2aY1o + 4kz99VitUg7UoWPN5ryL8sxU8NLRAdwU0w+K1a0HAoGAZaU7O94u9IIPZ6Ohobs2 + Vjf/eV0brCKgX61b4z/YhuJdZsyTujhBZUihZwqR696kiFKuzmHx1ghE2ITvnPVN + q0iHxRZzBCnRQ+mQlS0trzphaCP0NVy3osFeAD9mJfnOnSmkU0ua4F81mkvke1eN + 6nnaoAdy2lmMr96/Tye2ty4= + -----END PRIVATE KEY----- + + # openssl rsa -in private.pem -pubout -out public.pem + DJANGO_TOKEN_VERIFYING_KEY: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiK6NEIiwMcO6xRjtDYAF + iX5KWBOcrDBgwmyHdOx1K7/v0gqEY6ilcxiVSL5jT+aAcs0noT4LMFvb993antq/ + bAktScjGvlzt6n2QkdLkQ3DwsGYyMq9EEC6VgMpnQWvG2nh7FNf9zq43G1a6/pTc + eB0CTokp9PeIlz0Xlv9uYWHqv0l7O/VdgkfOzxnaGQIW/CJZQMSYHX5Hgf4lqIWP + 9b30U5iLjMHlhl5NGisz7AXbxPw6huB44jVRD4dfBt74ndGE2TZpu7vLzgrnt8fT + UFhhwOELcTbS7cZR3DhK8qmfQoTtpwvnsNF5f7+9OlxTcixYnI4rCCjUKKnZGjEt + 9wIDAQAB + -----END PUBLIC KEY----- + + # openssl rand -base64 32 + DJANGO_SECRETS_ENCRYPTION_KEY: "qYAIWnRK52aBT5YQkBoMEw08j7j3+QIPZXS6+A8Su44=" + + # openssl rand -base64 32 + AUTH_SECRET: "CM9w3Nco2P1RdHaYmD+fmy2nJmSofusdHd4g7Z4KDG4=" + + # Unfortunatelly, we need to duplicate the password in two different keys because the Neo4j Helm Chart expects the password in the NEO4J_AUTH key and the application expects it in the NEO4J_PASSWORD key. + NEO4J_PASSWORD: "prowler-password-fake" + NEO4J_AUTH: "neo4j/prowler-password-fake" diff --git a/contrib/k8s/helm/prowler-app/examples/minimal-installation/values.yaml b/contrib/k8s/helm/prowler-app/examples/minimal-installation/values.yaml new file mode 100644 index 0000000000..9ac8dda9e9 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/examples/minimal-installation/values.yaml @@ -0,0 +1,11 @@ +ui: + ingress: + enabled: true + hosts: + - host: 127.0.0.1.nip.io + paths: + - path: / + pathType: ImplementationSpecific + +# or use authUrl if you use prowler behind a proxy +# authUrl: 127.0.0.1.nip.io diff --git a/contrib/k8s/helm/prowler-app/templates/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/_helpers.tpl new file mode 100644 index 0000000000..7698fbfa18 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/_helpers.tpl @@ -0,0 +1,134 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "prowler.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "prowler.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "prowler.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "prowler.labels" -}} +helm.sh/chart: {{ include "prowler.chart" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Django environment variables for api, worker, and worker_beat. +*/}} +{{- define "prowler.django.env" -}} +- name: DJANGO_TOKEN_SIGNING_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.djangoTokenSigningKey.secretKeyRef.name }} + key: {{ .Values.djangoTokenSigningKey.secretKeyRef.key }} +- name: DJANGO_TOKEN_VERIFYING_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.djangoTokenVerifyingKey.secretKeyRef.name }} + key: {{ .Values.djangoTokenVerifyingKey.secretKeyRef.key }} +- name: DJANGO_SECRETS_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.djangoSecretsEncryptionKey.secretKeyRef.name }} + key: {{ .Values.djangoSecretsEncryptionKey.secretKeyRef.key }} +{{- end }} + + +{{/* +PostgreSQL environment variables for api, worker, and worker_beat. +Outputs nothing when postgresql.enabled is false. +*/}} +{{- define "prowler.postgresql.env" -}} +{{- if .Values.postgresql.enabled }} +{{- if .Values.postgresql.auth.username }} +- name: POSTGRES_USER + value: {{ .Values.postgresql.auth.username | quote }} +{{- end }} +- name: POSTGRES_PASSWORD +{{- if .Values.postgresql.auth.existingSecret }} + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.auth.existingSecret }} + key: {{ required "postgresql.auth.secretKeys.userPasswordKey is required when using an existing secret" .Values.postgresql.auth.secretKeys.userPasswordKey }} +{{- else if .Values.postgresql.auth.password }} + value: {{ .Values.postgresql.auth.password | quote }} +{{- else }} + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgresql + key: password +{{- end }} +- name: POSTGRES_DB + value: {{ .Values.postgresql.auth.database | quote }} +- name: POSTGRES_HOST + value: {{ .Release.Name }}-postgresql +- name: POSTGRES_PORT + value: "5432" +- name: POSTGRES_ADMIN_USER + value: postgres +- name: POSTGRES_ADMIN_PASSWORD +{{- if .Values.postgresql.auth.existingSecret }} + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.auth.existingSecret }} + key: {{ required "postgresql.auth.secretKeys.adminPasswordKey is required when using an existing secret" .Values.postgresql.auth.secretKeys.adminPasswordKey }} +{{- else if .Values.postgresql.auth.postgresPassword }} + value: {{ .Values.postgresql.auth.postgresPassword | quote }} +{{- else }} + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgresql + key: postgres-password +{{- end }} +{{- end }} +{{- end }} + +{{/* +Neo4j environment variables for api, worker, and worker_beat. +Outputs nothing when neo4j.enabled is false. +*/}} +{{- define "prowler.neo4j.env" -}} +{{- if .Values.neo4j.enabled }} +- name: NEO4J_HOST + value: {{ .Release.Name }} +- name: NEO4J_PORT + value: "7687" +- name: NEO4J_USER + value: "neo4j" +- name: NEO4J_PASSWORD + valueFrom: + secretKeyRef: + name: {{ required "neo4j.neo4j.passwordFromSecret is required" .Values.neo4j.neo4j.passwordFromSecret }} + key: NEO4J_PASSWORD +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/api/_helpers.tpl new file mode 100644 index 0000000000..55ac97f0d8 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Create the name of the service account to use +*/}} +{{- define "prowler.api.serviceAccountName" -}} +{{- if .Values.api.serviceAccount.create }} +{{- default (printf "%s-%s" (include "prowler.fullname" .) "api") .Values.api.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.api.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml new file mode 100644 index 0000000000..8e219a9271 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml @@ -0,0 +1,10 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.api.djangoConfig }} + {{ $key }}: {{ $value | quote }} + {{- end }} \ No newline at end of file diff --git a/contrib/k8s/helm/prowler-app/templates/api/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/api/deployment.yaml new file mode 100644 index 0000000000..f7a16b66ae --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/deployment.yaml @@ -0,0 +1,105 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + {{- if not .Values.api.autoscaling.enabled }} + replicas: {{ .Values.api.replicaCount }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-api + template: + metadata: + annotations: + secret-hash: "{{ printf "%s%s%s" (.Files.Get "templates/api/configmap.yaml" | sha256sum) (.Files.Get "templates/api/secret-valkey.yaml" | sha256sum) | sha256sum }}" + {{- with .Values.api.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "prowler.labels" . | nindent 8 }} + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-api + {{- with .Values.api.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.api.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "prowler.api.serviceAccountName" . }} + {{- with .Values.api.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: api + {{- with .Values.api.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.api.image.pullPolicy }} + {{- with .Values.api.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.api.service.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "prowler.fullname" . }}-api + {{- if .Values.valkey.enabled }} + - secretRef: + name: {{ include "prowler.fullname" . }}-api-valkey + {{- end }} + {{- with .Values.api.secrets }} + {{- range $index, $secret := . }} + - secretRef: + name: {{ $secret }} + {{- end }} + {{- end }} + env: + {{- include "prowler.django.env" . | nindent 12 }} + {{- include "prowler.postgresql.env" . | nindent 12 }} + {{- include "prowler.neo4j.env" . | nindent 12 }} + {{- with .Values.api.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/hpa.yaml b/contrib/k8s/helm/prowler-app/templates/api/hpa.yaml new file mode 100644 index 0000000000..c3d77d7e44 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.api.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "prowler.fullname" . }}-api + minReplicas: {{ .Values.api.autoscaling.minReplicas }} + maxReplicas: {{ .Values.api.autoscaling.maxReplicas }} + metrics: + {{- if .Values.api.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.api.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.api.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.api.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/ingress.yaml b/contrib/k8s/helm/prowler-app/templates/api/ingress.yaml new file mode 100644 index 0000000000..4118d9cd7a --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.api.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.api.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.api.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.api.ingress.tls }} + tls: + {{- range .Values.api.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.api.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "prowler.fullname" $ }}-api + port: + number: {{ $.Values.api.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/role.yaml b/contrib/k8s/helm/prowler-app/templates/api/role.yaml new file mode 100644 index 0000000000..172b035076 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/role.yaml @@ -0,0 +1,29 @@ +# https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/#step-44-kubernetes-credentials +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +rules: +- apiGroups: [""] + resources: ["pods", "configmaps", "nodes", "namespaces"] + verbs: ["get", "list", "watch"] +- apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterrolebindings", "rolebindings", "clusterroles", "roles"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "prowler.fullname" . }}-api +subjects: +- kind: ServiceAccount + name: {{ include "prowler.api.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} \ No newline at end of file diff --git a/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml b/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml new file mode 100644 index 0000000000..7778d06731 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml @@ -0,0 +1,13 @@ +{{- if .Values.valkey.enabled -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "prowler.fullname" . }}-api-valkey + labels: + {{- include "prowler.labels" . | nindent 4 }} +type: Opaque +stringData: + VALKEY_HOST: "{{ include "prowler.fullname" . }}-valkey" + VALKEY_PORT: "6379" + VALKEY_DB: "0" +{{- end -}} diff --git a/contrib/k8s/helm/prowler-app/templates/api/service.yaml b/contrib/k8s/helm/prowler-app/templates/api/service.yaml new file mode 100644 index 0000000000..9a42979306 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + type: {{ .Values.api.service.type }} + ports: + - port: {{ .Values.api.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-api diff --git a/contrib/k8s/helm/prowler-app/templates/api/serviceaccount.yaml b/contrib/k8s/helm/prowler-app/templates/api/serviceaccount.yaml new file mode 100644 index 0000000000..4d76d7f54e --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.api.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "prowler.api.serviceAccountName" . }} + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.api.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.api.serviceAccount.automount }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/ui/_helpers.tpl new file mode 100644 index 0000000000..8bdf93ba5f --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Create the name of the service account to use +*/}} +{{- define "prowler.ui.serviceAccountName" -}} +{{- if .Values.ui.serviceAccount.create }} +{{- default (printf "%s-%s" (include "prowler.fullname" .) "ui") .Values.ui.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.ui.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml new file mode 100644 index 0000000000..38d6e65ee3 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml @@ -0,0 +1,18 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: {{ include "prowler.fullname" . }}-ui +data: + PROWLER_UI_VERSION: "stable" + {{- if .Values.ui.ingress.enabled }} + {{- with (first .Values.ui.ingress.hosts) }} + AUTH_URL: "https://{{ .host }}" + {{- end }} + {{- else }} + AUTH_URL: {{ .Values.ui.authUrl | quote }} + {{- end }} + API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" + AUTH_TRUST_HOST: "true" + UI_PORT: {{ .Values.ui.service.port | quote }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/ui/deployment.yaml new file mode 100644 index 0000000000..f7bf2c17fe --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/deployment.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "prowler.fullname" . }}-ui + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + {{- if not .Values.ui.autoscaling.enabled }} + replicas: {{ .Values.ui.replicaCount }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-ui + template: + metadata: + annotations: + secret-hash: {{ .Files.Get "templates/ui/configmap.yaml" | sha256sum }} + {{- with .Values.ui.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "prowler.labels" . | nindent 8 }} + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-ui + {{- with .Values.ui.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.ui.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "prowler.ui.serviceAccountName" . }} + {{- with .Values.ui.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: ui + {{- with .Values.ui.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.ui.image.repository }}:{{ .Values.ui.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.ui.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.ui.service.port }} + protocol: TCP + env: + - name: AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.ui.authSecret.secretKeyRef.name }} + key: {{ .Values.ui.authSecret.secretKeyRef.key }} + envFrom: + - configMapRef: + name: {{ include "prowler.fullname" . }}-ui + {{- with .Values.ui.secrets }} + {{- range $index, $secret := . }} + - secretRef: + name: {{ $secret }} + {{- end }} + {{- end }} + {{- with .Values.ui.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.ui.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.ui.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.ui.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.ui.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.ui.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.ui.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.ui.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/hpa.yaml b/contrib/k8s/helm/prowler-app/templates/ui/hpa.yaml new file mode 100644 index 0000000000..7c6716ef1f --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.ui.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "prowler.fullname" . }}-ui + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "prowler.fullname" . }}-ui + minReplicas: {{ .Values.ui.autoscaling.minReplicas }} + maxReplicas: {{ .Values.ui.autoscaling.maxReplicas }} + metrics: + {{- if .Values.ui.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.ui.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.ui.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.ui.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/ingress.yaml b/contrib/k8s/helm/prowler-app/templates/ui/ingress.yaml new file mode 100644 index 0000000000..74dcecabe1 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ui.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "prowler.fullname" . }}-ui + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.ui.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ui.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ui.ingress.tls }} + tls: + {{- range .Values.ui.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ui.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "prowler.fullname" $ }}-ui + port: + number: {{ $.Values.ui.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/service.yaml b/contrib/k8s/helm/prowler-app/templates/ui/service.yaml new file mode 100644 index 0000000000..9b845e5b5f --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "prowler.fullname" . }}-ui + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + type: {{ .Values.ui.service.type }} + ports: + - port: {{ .Values.ui.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-ui diff --git a/contrib/k8s/helm/prowler-app/templates/ui/serviceaccount.yaml b/contrib/k8s/helm/prowler-app/templates/ui/serviceaccount.yaml new file mode 100644 index 0000000000..91b176a64e --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.ui.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "prowler.ui.serviceAccountName" . }} + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.ui.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.ui.serviceAccount.automount }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/worker/_helpers.tpl new file mode 100644 index 0000000000..3a99d42cb6 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Create the name of the service account to use +*/}} +{{- define "prowler.worker.serviceAccountName" -}} +{{- if .Values.worker.serviceAccount.create }} +{{- default (printf "%s-%s" (include "prowler.fullname" .) "worker") .Values.worker.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.worker.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml new file mode 100644 index 0000000000..6c11a28f9b --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml @@ -0,0 +1,101 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "prowler.fullname" . }}-worker + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + {{- if not .Values.worker.autoscaling.enabled }} + replicas: {{ .Values.worker.replicaCount }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-worker + template: + metadata: + annotations: + secret-hash: "{{ printf "%s%s%s" (.Files.Get "templates/api/configmap.yaml" | sha256sum) (.Files.Get "templates/api/secret-valkey.yaml" | sha256sum) | sha256sum }}" + {{- with .Values.worker.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "prowler.labels" . | nindent 8 }} + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-worker + {{- with .Values.worker.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.worker.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "prowler.worker.serviceAccountName" . }} + {{- with .Values.worker.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: worker + {{- with .Values.worker.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.worker.image.pullPolicy }} + {{- with .Values.worker.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "prowler.fullname" . }}-api + {{- if .Values.valkey.enabled }} + - secretRef: + name: {{ include "prowler.fullname" . }}-api-valkey + {{- end }} + {{- with .Values.api.secrets }} + {{- range $index, $secret := . }} + - secretRef: + name: {{ $secret }} + {{- end }} + {{- end }} + env: + {{- include "prowler.django.env" . | nindent 12 }} + {{- include "prowler.postgresql.env" . | nindent 12 }} + {{- include "prowler.neo4j.env" . | nindent 12 }} + {{- with .Values.worker.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/hpa.yaml b/contrib/k8s/helm/prowler-app/templates/worker/hpa.yaml new file mode 100644 index 0000000000..d77ab3f47e --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.worker.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "prowler.fullname" . }}-worker + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "prowler.fullname" . }}-worker + minReplicas: {{ .Values.worker.autoscaling.minReplicas }} + maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }} + metrics: + {{- if .Values.worker.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.worker.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.worker.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/scaled-object.yaml b/contrib/k8s/helm/prowler-app/templates/worker/scaled-object.yaml new file mode 100644 index 0000000000..98ae3ae9d5 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/scaled-object.yaml @@ -0,0 +1,32 @@ +{{- if .Values.worker.keda.enabled }} +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: {{ include "prowler.fullname" . }}-worker + namespace: {{ $.Release.Namespace }} + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + scaleTargetRef: + name: {{ include "prowler.fullname" . }}-worker + envSourceContainerName: worker + kind: Deployment + minReplicaCount: {{ .Values.worker.keda.minReplicas }} + maxReplicaCount: {{ .Values.worker.keda.maxReplicas }} + pollingInterval: {{ .Values.worker.keda.pollingInterval }} + cooldownPeriod: {{ .Values.worker.keda.cooldownPeriod }} + triggers: + - type: {{ .Values.worker.keda.triggerType }} + metadata: + userName: "postgres" + passwordFromEnv: POSTGRES_ADMIN_PASSWORD + host: {{ .Release.Name }}-postgresql + port: {{ .Values.postgresql.port | quote }} + dbName: {{ .Values.postgresql.auth.database | quote }} + sslmode: disable + # Query for KEDA to count the number of scans that are in executing, available, or scheduled states, + # where the scheduled time is within the last 2 hours and is before NOW(). Used for scaling workers. + query: >- + SELECT COUNT(*) FROM scans WHERE ((state='executing' OR state='available' OR state='scheduled') and scheduled_at < NOW() and scheduled_at > NOW() - INTERVAL '2 hours') + targetQueryValue: "1" +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/serviceaccount.yaml b/contrib/k8s/helm/prowler-app/templates/worker/serviceaccount.yaml new file mode 100644 index 0000000000..8974d3ce04 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.worker.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "prowler.worker.serviceAccountName" . }} + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.worker.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.worker.serviceAccount.automount }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker_beat/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/worker_beat/_helpers.tpl new file mode 100644 index 0000000000..b9ce287667 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker_beat/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Create the name of the service account to use +*/}} +{{- define "prowler.worker_beat.serviceAccountName" -}} +{{- if .Values.worker_beat.serviceAccount.create }} +{{- default (printf "%s-%s" (include "prowler.fullname" .) "worker-beat") .Values.worker_beat.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.worker_beat.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml new file mode 100644 index 0000000000..749ea946fd --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml @@ -0,0 +1,99 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "prowler.fullname" . }}-worker-beat + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.worker_beat.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-worker-beat + template: + metadata: + annotations: + secret-hash: "{{ printf "%s%s%s" (.Files.Get "templates/api/configmap.yaml" | sha256sum) (.Files.Get "templates/api/secret-valkey.yaml" | sha256sum) | sha256sum }}" + {{- with .Values.worker.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "prowler.labels" . | nindent 8 }} + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-worker-beat + {{- with .Values.worker_beat.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.worker_beat.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "prowler.worker_beat.serviceAccountName" . }} + {{- with .Values.worker_beat.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: worker-beat + {{- with .Values.worker_beat.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.worker_beat.image.repository }}:{{ .Values.worker_beat.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.worker_beat.image.pullPolicy }} + {{- with .Values.worker_beat.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "prowler.fullname" . }}-api + {{- if .Values.valkey.enabled }} + - secretRef: + name: {{ include "prowler.fullname" . }}-api-valkey + {{- end }} + {{- with .Values.api.secrets }} + {{- range $index, $secret := . }} + - secretRef: + name: {{ $secret }} + {{- end }} + {{- end }} + env: + {{- include "prowler.django.env" . | nindent 12 }} + {{- include "prowler.postgresql.env" . | nindent 12 }} + {{- include "prowler.neo4j.env" . | nindent 12 }} + {{- with .Values.worker_beat.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker_beat.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker_beat.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker_beat.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker_beat/serviceaccount.yaml b/contrib/k8s/helm/prowler-app/templates/worker_beat/serviceaccount.yaml new file mode 100644 index 0000000000..9718686c2a --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker_beat/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.worker_beat.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "prowler.worker_beat.serviceAccountName" . }} + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.worker_beat.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.worker_beat.serviceAccount.automount }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml new file mode 100644 index 0000000000..46258351ad --- /dev/null +++ b/contrib/k8s/helm/prowler-app/values.yaml @@ -0,0 +1,566 @@ +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# Reference to the secret containing the API authentication secret. +# Used to inject the environment variable for the API container. +djangoTokenSigningKey: + secretKeyRef: + name: prowler-secret + key: DJANGO_TOKEN_SIGNING_KEY +djangoTokenVerifyingKey: + secretKeyRef: + name: prowler-secret + key: DJANGO_TOKEN_VERIFYING_KEY +djangoSecretsEncryptionKey: + secretKeyRef: + name: prowler-secret + key: DJANGO_SECRETS_ENCRYPTION_KEY + +ui: + # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ + replicaCount: 1 + + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ + image: + repository: prowlercloud/prowler-ui + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + + # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + imagePullSecrets: [] + + # Reference to the secret containing the UI authentication secret. + # Used to inject the environment variable for the UI container. + # By default, expects a Secret named 'prowler-secret' with a key 'AUTH_SECRET'. + authSecret: + secretKeyRef: + name: prowler-secret + key: AUTH_SECRET + + # Secret names to be used as env vars. + secrets: [] + # - "prowler-ui-secret" + + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ + serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + # This is for setting Kubernetes Annotations to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + # This is for setting Kubernetes Labels to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + podSecurityContext: {} + # fsGroup: 2000 + + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + # This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ + service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 3000 + + # The URL of the UI. This is only set if ingress is disabled. + authUrl: "" + + # This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ + ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + + resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + + # This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + # Additional volumes on the output Deployment definition. + volumes: [] + # - name: foo + # secret: + # secretName: mysecret + # optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: [] + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true + + nodeSelector: {} + + tolerations: [] + + affinity: {} + +api: + # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ + replicaCount: 1 + + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ + image: + repository: prowlercloud/prowler-api + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + + # Shared with celery-worker and celery-beat + djangoConfig: + # API scan settings + # The path to the directory where scan output should be stored + DJANGO_TMP_OUTPUT_DIRECTORY: "/tmp/prowler_api_output" + # The maximum number of findings to process in a single batch + DJANGO_FINDINGS_BATCH_SIZE: "1000" + # Django settings + DJANGO_ALLOWED_HOSTS: "*" + DJANGO_BIND_ADDRESS: "0.0.0.0" + DJANGO_PORT: "8080" + DJANGO_DEBUG: "False" + DJANGO_SETTINGS_MODULE: "config.django.production" + # Select one of [ndjson|human_readable] + DJANGO_LOGGING_FORMATTER: "ndjson" + # Select one of [DEBUG|INFO|WARNING|ERROR|CRITICAL] + # Applies to both Django and Celery Workers + DJANGO_LOGGING_LEVEL: "INFO" + # Defaults to the maximum available based on CPU cores if not set. + DJANGO_WORKERS: "4" + # Token lifetime is in minutes + DJANGO_ACCESS_TOKEN_LIFETIME: "30" + # Token lifetime is in minutes + DJANGO_REFRESH_TOKEN_LIFETIME: "1440" + DJANGO_CACHE_MAX_AGE: "3600" + DJANGO_STALE_WHILE_REVALIDATE: "60" + DJANGO_MANAGE_DB_PARTITIONS: "True" + DJANGO_BROKER_VISIBILITY_TIMEOUT: "86400" + + # Secret names to be used as env vars for api, worker, and worker_beat. + secrets: [] + # - "prowler-api-keys" + + command: + - /home/prowler/docker-entrypoint.sh + args: + - prod + + # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + imagePullSecrets: [] + + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ + serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + # This is for setting Kubernetes Annotations to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + # This is for setting Kubernetes Labels to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + podSecurityContext: {} + # fsGroup: 2000 + + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + # This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ + service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 8080 + + # This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ + ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + + resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # 3m30s to setup DB + # startupProbe: + # httpGet: + # path: /api/v1/docs + # port: http + + # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + livenessProbe: + failureThreshold: 10 + httpGet: + path: /api/v1/docs + port: http + periodSeconds: 20 + readinessProbe: + failureThreshold: 10 + httpGet: + path: /api/v1/docs + port: http + periodSeconds: 20 + + # This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + # Additional volumes on the output Deployment definition. + volumes: [] + # - name: foo + # secret: + # secretName: mysecret + # optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: [] + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true + + nodeSelector: {} + + tolerations: [] + + affinity: {} + +worker: + # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ + replicaCount: 1 + + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ + image: + repository: prowlercloud/prowler-api + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + + command: + - /home/prowler/docker-entrypoint.sh + args: + - worker + + # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + imagePullSecrets: [] + + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ + serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + # This is for setting Kubernetes Annotations to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + # This is for setting Kubernetes Labels to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + podSecurityContext: {} + # fsGroup: 2000 + + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + livenessProbe: {} + readinessProbe: {} + + # This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + # Additional volumes on the output Deployment definition. + volumes: [] + # - name: foo + # secret: + # secretName: mysecret + # optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: [] + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true + + nodeSelector: {} + + tolerations: [] + + affinity: {} + + # KEDA ScaledObject configuration + keda: + # -- Set to `true` to enable KEDA for the worker pods + # Note: When both KEDA and HPA are enabled, the deployment will fail. + enabled: false + # -- The minimum number of replicas to use for the worker pods + minReplicas: 1 + # -- The maximum number of replicas to use for the worker pods + maxReplicas: 2 + # -- The polling interval in seconds for checking metrics + pollingInterval: 30 + # -- The cooldown period in seconds for scaling + cooldownPeriod: 120 + # -- The trigger type for scaling (cpu or memory) + triggerType: "postgresql" + # -- The target utilization percentage for the worker pods + value: "50" + +worker_beat: + # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ + replicaCount: 1 + + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ + image: + repository: prowlercloud/prowler-api + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + + command: + - ../docker-entrypoint.sh + args: + - beat + + # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + imagePullSecrets: [] + + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ + serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + # This is for setting Kubernetes Annotations to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + # This is for setting Kubernetes Labels to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + podSecurityContext: {} + # fsGroup: 2000 + + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + livenessProbe: {} + readinessProbe: {} + + # Additional volumes on the output Deployment definition. + volumes: [] + # - name: foo + # secret: + # secretName: mysecret + # optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: [] + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true + + nodeSelector: {} + + tolerations: [] + + affinity: {} + +postgresql: + # -- Enable PostgreSQL deployment (via Bitnami Helm Chart). If you want to use an external Postgres server (or a managed one), set this to false + # If enabled, it will create a Secret with the credentials. + # Otherwise, create a secret with the following and add it to the api deployment: + # - POSTGRES_HOST + # - POSTGRES_PORT + # - POSTGRES_ADMIN_USER - Existing user in charge of migrations, tables, permissions, RLS + # - POSTGRES_ADMIN_PASSWORD + # - POSTGRES_USER - Will be created by ADMIN_USER + # - POSTGRES_PASSWORD + # - POSTGRES_DB - Existing DB + enabled: true + image: + repository: "bitnami/postgresql" + auth: + database: prowler_db + username: prowler + +valkey: + # If enabled, it will create a Secret with the following. + # Otherwise, create a secret with + # - VALKEY_HOST + # - VALKEY_PORT + # - VALKEY_DB + enabled: true + +neo4j: + enabled: true + + neo4j: + name: prowler-neo4j + edition: community + + # The name of the secret containing the Neo4j password with the key NEO4J_PASSWORD + passwordFromSecret: prowler-secret + + # Disable lookups during helm template rendering (required for ArgoCD) + disableLookups: true + + volumes: + data: + mode: defaultStorageClass + + services: + neo4j: + enabled: false + + # Neo4j Configuration (yaml format) + config: + dbms_security_procedures_allowlist: "apoc.*" + dbms_security_procedures_unrestricted: "apoc.*" + + apoc_config: + apoc.export.file.enabled: "true" + apoc.import.file.enabled: "true" + apoc.import.file.use_neo4j_config: "true" diff --git a/dashboard/compliance/cis_3_1_oraclecloud.py b/dashboard/compliance/cis_3_1_oraclecloud.py new file mode 100644 index 0000000000..7d51acf0f4 --- /dev/null +++ b/dashboard/compliance/cis_3_1_oraclecloud.py @@ -0,0 +1,41 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + """ + Generate CIS OCI Foundations Benchmark v3.1 compliance table. + + Args: + data: DataFrame containing compliance check results with columns: + - REQUIREMENTS_ID: CIS requirement ID (e.g., "1.1", "2.1") + - REQUIREMENTS_DESCRIPTION: Description of the requirement + - REQUIREMENTS_ATTRIBUTES_SECTION: CIS section name + - CHECKID: Prowler check identifier + - STATUS: Check status (PASS/FAIL) + - REGION: OCI region + - ACCOUNTID: OCI tenancy OCID (renamed from TENANCYID) + - RESOURCEID: Resource OCID or identifier + + Returns: + Section containers organized by CIS sections for dashboard display + """ + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index f944f7f098..20395539e5 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -284,6 +284,11 @@ def display_data( # Rename the column LOCATION to REGION for Alibaba Cloud if "alibabacloud" in analytics_input: data = data.rename(columns={"LOCATION": "REGION"}) + + # Rename the column TENANCYID to ACCOUNTID for Oracle Cloud + if "oraclecloud" in analytics_input: + data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True) + # Filter the chosen level of the CIS if is_level_1: data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")] diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index e2502c9aad..665aa8e195 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -259,6 +259,8 @@ else: accounts.append(account + " - K8S") if "alibabacloud" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): accounts.append(account + " - ALIBABACLOUD") + if "oraclecloud" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): + accounts.append(account + " - OCI") account_dropdown = create_account_dropdown(accounts) @@ -306,6 +308,8 @@ else: services.append(service + " - M365") if "alibabacloud" in list(data[data["SERVICE_NAME"] == service]["PROVIDER"]): services.append(service + " - ALIBABACLOUD") + if "oraclecloud" in list(data[data["SERVICE_NAME"] == service]["PROVIDER"]): + services.append(service + " - OCI") services = ["All"] + services services = [ @@ -767,6 +771,8 @@ def filter_data( all_account_ids.append(account) if "alibabacloud" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): all_account_ids.append(account) + if "oraclecloud" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): + all_account_ids.append(account) all_account_names = [] if "ACCOUNT_NAME" in filtered_data.columns: @@ -793,6 +799,8 @@ def filter_data( data[data["ACCOUNT_UID"] == item]["PROVIDER"] ): cloud_accounts_options.append(item + " - ALIBABACLOUD") + if "oraclecloud" in list(data[data["ACCOUNT_UID"] == item]["PROVIDER"]): + cloud_accounts_options.append(item + " - OCI") if "ACCOUNT_NAME" in filtered_data.columns: if "azure" in list(data[data["ACCOUNT_NAME"] == item]["PROVIDER"]): cloud_accounts_options.append(item + " - AZURE") @@ -925,6 +933,10 @@ def filter_data( filtered_data[filtered_data["SERVICE_NAME"] == item]["PROVIDER"] ): service_filter_options.append(item + " - ALIBABACLOUD") + if "oraclecloud" in list( + filtered_data[filtered_data["SERVICE_NAME"] == item]["PROVIDER"] + ): + service_filter_options.append(item + " - OCI") # Filter Service if service_values == ["All"]: @@ -1124,6 +1136,7 @@ def filter_data( config={"displayModeBar": False}, ) table = dcc.Graph(figure=fig, config={"displayModeBar": False}) + table_row_options = [] else: # Status Pie Chart diff --git a/docs/docs.json b/docs/docs.json index 2f190ac36d..8305388369 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -255,6 +255,12 @@ "user-guide/providers/cloudflare/authentication" ] }, + { + "group": "Image", + "pages": [ + "user-guide/providers/image/getting-started-image" + ] + }, { "group": "LLM", "pages": [ diff --git a/docs/images/prowler-app/saml/okta-app-assignments.png b/docs/images/prowler-app/saml/okta-app-assignments.png new file mode 100644 index 0000000000..3881e646fc Binary files /dev/null and b/docs/images/prowler-app/saml/okta-app-assignments.png differ diff --git a/docs/images/prowler-app/saml/okta-user-profile-attributes.png b/docs/images/prowler-app/saml/okta-user-profile-attributes.png new file mode 100644 index 0000000000..beb6781b2c Binary files /dev/null and b/docs/images/prowler-app/saml/okta-user-profile-attributes.png differ diff --git a/docs/images/prowler-app/saml/okta-user-profile-name.png b/docs/images/prowler-app/saml/okta-user-profile-name.png new file mode 100644 index 0000000000..2a08d3437b Binary files /dev/null and b/docs/images/prowler-app/saml/okta-user-profile-name.png differ diff --git a/docs/introduction.mdx b/docs/introduction.mdx index eee557b4fc..19be547497 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -36,6 +36,7 @@ The supported providers right now are: | [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | CLI | | [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI | | [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | +| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | CLI | | [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | | **NHN** | Unofficial | Tenants | CLI | diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index d76a0a1cb5..de7ed163d6 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -38,6 +38,7 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained 4. **Configure Token Settings** - **Token name**: Give your token a descriptive name (e.g., "Prowler Security Scanner") + - **Resource owner**: Select the account that owns the resources to scan — either a personal account or a specific organization - **Expiration**: Set an appropriate expiration date (recommended: 90 days or less) - **Repository access**: Choose "All repositories" or "Only select repositories" based on your needs @@ -56,11 +57,11 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained - **Metadata**: Read-only access - **Pull requests**: Read-only access - - **Organization permissions:** + - **Organization permissions** (available when an organization is selected as Resource Owner): - **Administration**: Read-only access - **Members**: Read-only access - - **Account permissions:** + - **Account permissions** (available when a personal account is selected as Resource Owner): - **Email addresses**: Read-only access 6. **Copy and Store the Token** diff --git a/docs/user-guide/providers/github/getting-started-github.mdx b/docs/user-guide/providers/github/getting-started-github.mdx index 41d472df61..a9f870269b 100644 --- a/docs/user-guide/providers/github/getting-started-github.mdx +++ b/docs/user-guide/providers/github/getting-started-github.mdx @@ -54,7 +54,7 @@ title: 'Getting Started with GitHub' ## Prowler CLI -### Automatic Login Method Detection +### Authentication If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence: @@ -68,15 +68,15 @@ Ensure the corresponding environment variables are set up before running Prowler For more details on how to set up authentication with GitHub, see [Authentication > GitHub](/user-guide/providers/github/authentication). -### Personal Access Token (PAT) +#### Personal Access Token (PAT) -Use this method by providing your personal access token directly. +Use this method by providing a personal access token directly. ```console prowler github --personal-access-token pat ``` -### OAuth App Token +#### OAuth App Token Authenticate using an OAuth app token. @@ -84,9 +84,62 @@ Authenticate using an OAuth app token. prowler github --oauth-app-token oauth_token ``` -### GitHub App Credentials +#### GitHub App Credentials + Use GitHub App credentials by specifying the App ID and the private key path. ```console prowler github --github-app-id app_id --github-app-key-path app_key_path ``` + +### Scan Scoping + +By default, Prowler scans all repositories accessible to the authenticated user or organization. To limit the scan to specific repositories or organizations, use the following flags. + +#### Scanning Specific Repositories + +To restrict the scan to one or more repositories, use the `--repository` flag followed by the repository name(s) in `owner/repo-name` format: + +```console +prowler github --repository owner/repo-name +``` + +To scan multiple repositories, specify them as space-separated arguments: + +```console +prowler github --repository owner/repo-name-1 owner/repo-name-2 +``` + +#### Scanning Specific Organizations + +To restrict the scan to one or more organizations or user accounts, use the `--organization` flag: + +```console +prowler github --organization my-organization +``` + +To scan multiple organizations, specify them as space-separated arguments: + +```console +prowler github --organization org-1 org-2 +``` + +#### Scanning Specific Repositories Within an Organization + +To scan specific repositories within an organization, combine the `--organization` and `--repository` flags. The `--organization` flag qualifies unqualified repository names automatically: + +```console +prowler github --organization my-organization --repository my-repo +``` + +This scans only `my-organization/my-repo`. Fully qualified repository names (`owner/repo-name`) are also supported alongside `--organization`: + +```console +prowler github --organization my-org --repository my-repo other-owner/other-repo +``` + +In this case, `my-repo` is qualified as `my-org/my-repo`, while `other-owner/other-repo` is used as-is. + + +The `--repository` and `--organization` flags can be combined with any authentication method. + diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx new file mode 100644 index 0000000000..bdae875633 --- /dev/null +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -0,0 +1,197 @@ +--- +title: "Getting Started with the Image Provider" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + +Prowler's Image provider enables comprehensive container image security scanning by integrating with [Trivy](https://trivy.dev/). This provider detects vulnerabilities, exposed secrets, and misconfigurations in container images, converting Trivy findings into Prowler's standard reporting format for unified security assessment. + +## How It Works + +* **Trivy integration:** Prowler leverages [Trivy](https://trivy.dev/) to scan container images for vulnerabilities, secrets, misconfigurations, and license issues. +* **Trivy required:** Trivy must be installed and available in the system PATH before running any scan. +* **Authentication:** No registry authentication is required for public images. For private registries, configure Docker credentials via `docker login` before scanning. +* **Output formats:** Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.). + +## Prowler CLI + + + + +The Image provider is currently available in Prowler CLI only. + + +### Install Trivy + +Install Trivy using one of the following methods: + + + + ```bash + brew install trivy + ``` + + + ```bash + sudo apt-get install trivy + ``` + + + ```bash + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + ``` + + + +For additional installation methods, see the [Trivy installation guide](https://trivy.dev/latest/getting-started/installation/). + + +### Supported Scanners + +Prowler CLI supports the following scanners: + +* [Vulnerability](https://trivy.dev/docs/latest/guide/scanner/vulnerability/) +* [Secret](https://trivy.dev/docs/latest/guide/scanner/secret/) +* [Misconfiguration](https://trivy.dev/docs/latest/guide/scanner/misconfiguration/) +* [License](https://trivy.dev/docs/latest/guide/scanner/license/) + +By default, only vulnerability and secret scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below. + +### Usage + +Use the `image` argument to run Prowler with the Image provider. Specify the images to scan using the `-I` flag or an image list file. + +#### Scan a Single Image + +To scan a single container image: + +```bash +prowler image -I alpine:3.18 +``` + +#### Scan Multiple Images + +To scan multiple images, repeat the `-I` flag: + +```bash +prowler image -I nginx:latest -I redis:7 -I python:3.12-slim +``` + +#### Scan From an Image List File + +For large-scale scanning, provide a file containing one image per line: + +```bash +prowler image --image-list images.txt +``` + +The file supports comments (lines starting with `#`) and blank lines: + +```text +# Production images +nginx:1.25 +redis:7-alpine + +# Development images +python:3.12-slim +node:20-bookworm +``` + + +Image list files are limited to a maximum of 10,000 lines. Individual image names exceeding 500 characters are automatically skipped with a warning. + + + +Image names must follow the Open Container Initiative (OCI) reference format. Valid names start with an alphanumeric character and contain only letters, digits, periods, hyphens, underscores, slashes, colons, and `@` symbols. Names containing shell metacharacters (`;`, `|`, `&`, `$`, `` ` ``) are rejected to prevent command injection. + + +Valid examples: +* `alpine:3.18` +* `myregistry.io/myapp:v1.0` +* `ghcr.io/org/image@sha256:abc123...` + +#### Specify Scanners + +To select which scanners Trivy runs, use the `--scanners` option. By default, Prowler enables `vuln` and `secret` scanners: + +```bash +# Vulnerability scanning only +prowler image -I alpine:3.18 --scanners vuln + +# All available scanners +prowler image -I alpine:3.18 --scanners vuln secret misconfig license +``` + + +#### Image Config Scanners + +To scan Dockerfile-level metadata for misconfigurations or embedded secrets, use the `--image-config-scanners` option: + +```bash +# Scan Dockerfile for misconfigurations +prowler image -I alpine:3.18 --image-config-scanners misconfig + +# Scan Dockerfile for both misconfigurations and secrets +prowler image -I alpine:3.18 --image-config-scanners misconfig secret +``` + +Available image config scanners: + +* **misconfig**: Detects Dockerfile misconfigurations (e.g., running as root, missing health checks) +* **secret**: Identifies secrets embedded in Dockerfile instructions + + +Image config scanners are disabled by default. This option is independent from `--scanners` and specifically targets the image configuration (Dockerfile) rather than the image filesystem. + + +#### Filter by Severity + +To filter findings by severity level, use the `--trivy-severity` option: + +```bash +# Only critical and high severity findings +prowler image -I alpine:3.18 --trivy-severity CRITICAL HIGH +``` + +Available severity levels: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `UNKNOWN`. + +#### Ignore Unfixed Vulnerabilities + +To exclude vulnerabilities without available fixes: + +```bash +prowler image -I alpine:3.18 --ignore-unfixed +``` + +#### Configure Scan Timeout + +To adjust the scan timeout for large images or slow network conditions, use the `--timeout` option: + +```bash +prowler image -I large-image:latest --timeout 10m +``` + +The timeout accepts values in seconds (`s`), minutes (`m`), or hours (`h`). Default: `5m`. + +### Authentication for Private Registries + +The Image provider relies on Trivy for registry authentication. To scan images from private registries, configure Docker credentials before running the scan: + +```bash +# Log in to a private registry +docker login myregistry.io + +# Then scan the image +prowler image -I myregistry.io/myapp:v1.0 +``` + +Trivy automatically uses credentials from Docker's credential store (`~/.docker/config.json`). + +### Troubleshooting Common Scan Errors + +The Image provider categorizes common Trivy errors with actionable guidance: + +* **Authentication failure (401/403):** Registry credentials are missing or invalid. Run `docker login` for the target registry and retry the scan. +* **Image not found (404):** The specified image name, tag, or registry is incorrect. Verify the image reference exists and is accessible. +* **Rate limited (429):** The container registry is throttling requests. Wait before retrying, or authenticate to increase rate limits. +* **Network issue:** Trivy cannot reach the registry due to connectivity problems. Check network access, DNS resolution, and firewall rules. diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index fa062100d1..9539bfaa45 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -79,6 +79,31 @@ Choose a Method: + **Configure Attribute Mapping in the IdP** + + For Prowler App to correctly identify and provision users, configure the IdP to send the following attributes in the SAML assertion: + + | Attribute Name | Description | Required | + |----------------|---------------------------------------------------------------------------------------------------------|----------| + | `firstName` | The user's first name. | Yes | + | `lastName` | The user's last name. | Yes | + | `userType` | The Prowler role to be assigned to the user (e.g., `admin`, `auditor`). If a role with that name already exists, it will be used; if it does not exist, a new role with that name will be created without permissions. If `userType` is not defined, the user is assigned the `no_permissions` role. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | + | `companyName` | The user's company name. This is automatically populated if the IdP sends an `organization` attribute. | No | + + + **IdP Attribute Mapping** + + Note that the attribute name is just an example and may be different depending on the IdP. For instance, if the IdP provides a `division` attribute, it can be mapped to `userType`. + ![IdP configuration](/images/prowler-app/saml/saml_attribute_statements.png) + + + + **Dynamic Updates** + + Prowler App updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again. + + + Instead of creating a custom SAML integration, Okta administrators can configure Prowler Cloud directly from Okta's application catalog. @@ -105,39 +130,48 @@ Choose a Method: 6. **Assign Users**: Navigate to the "Assignments" tab and assign the appropriate users or groups to the Prowler application by clicking "Assign" and selecting "Assign to People" or "Assign to Groups". + ![Okta App Assignments](/images/prowler-app/saml/okta-app-assignments.png) + + 7. **Configure User Attributes in Okta**: Okta acts as the central source for user profile information. Prowler App maps the following Okta user profile attributes during each SAML login: + + * **First name** (`firstName`): Maps to the user's first name in Prowler App. + * **Last name** (`lastName`): Maps to the user's last name in Prowler App. + + ![Okta User Profile — First Name and Last Name](/images/prowler-app/saml/okta-user-profile-name.png) + + * **Organization** (`organization`): Maps to the company name displayed in Prowler App. This attribute is optional. + * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive** and must match the exact name of an existing role in Prowler App. + + ![Okta User Profile — User Type and Organization](/images/prowler-app/saml/okta-user-profile-attributes.png) + + To modify these values, edit the user's profile directly in the Okta admin console under the "Profile" tab. Changes are reflected in Prowler App the next time the user logs in via SAML. + + + **User Type and Role Assignment** + + The `userType` attribute controls which Prowler role is assigned to the user: + + * If a role with the specified name already exists in Prowler App, the user automatically receives that role. + * If the role does not exist, Prowler App creates a new role with that exact name but without any permissions, preventing the user from performing any actions. + * If `userType` is not defined in the user's Okta profile, the user is assigned the `no_permissions` role. + + In all cases where the resulting role has no permissions, a Prowler administrator (a user whose role includes the "Manage Account" permission) must configure the appropriate permissions through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). + + This behavior is intentional: by defaulting to no permissions, Prowler App ensures that a misconfiguration in Okta cannot inadvertently grant elevated access. + + **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` without permissions, and a Prowler administrator must configure the desired permissions for it. + + + With this step, the Okta app catalog configuration is complete. Users can now access Prowler Cloud using either [IdP-initiated](#idp-initiated-sso) or [SP-initiated SSO](#sp-initiated-sso) flows. - 7. **Download Metadata XML**: Inside the "Sign On" section, go to the "Metadata URL" and download the metadata XML file. + 8. **Download Metadata XML**: Inside the "Sign On" section, go to the "Metadata URL" and download the metadata XML file. - Jump to [Step 5: Upload IdP Metadata to Prowler](#step-5:-upload-idp-metadata-to-prowler). + Jump to [Step 4: Upload IdP Metadata to Prowler](#step-4:-upload-idp-metadata-to-prowler). -#### Step 4: Configure Attribute Mapping in the IdP - -For Prowler App to correctly identify and provision users, configure the IdP to send the following attributes in the SAML assertion: - -| Attribute Name | Description | Required | -|----------------|---------------------------------------------------------------------------------------------------------|----------| -| `firstName` | The user's first name. | Yes | -| `lastName` | The user's last name. | Yes | -| `userType` | The Prowler role to be assigned to the user (e.g., `admin`, `auditor`). If a role with that name already exists, it will be used; otherwise, a new role called `no_permissions` will be created with minimal permissions. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | -| `companyName` | The user's company name. This is automatically populated if the IdP sends an `organization` attribute. | No | - - -**IdP Attribute Mapping** - -Note that the attribute name is just an example and may be different depending on the IdP. For instance, if the IdP provides a `division` attribute, it can be mapped to `userType`. -![IdP configuration](/images/prowler-app/saml/saml_attribute_statements.png) - - - -**Dynamic Updates** - -Prowler App updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again. - - -#### Step 5: Upload IdP Metadata to Prowler +#### Step 4: Upload IdP Metadata to Prowler Once the IdP is configured, it provides a **metadata XML file**. This file contains the IdP's configuration information, such as its public key and login URL. @@ -151,7 +185,7 @@ To complete the Prowler App configuration: ![Configure Prowler with IdP Metadata](/images/prowler-app/saml/saml-step-3.png) -#### Step 6: Save and Verify Configuration +#### Step 5: Save and Verify Configuration Click the "Save" button to complete the setup. The "SAML Integration" card will now display an "Active" status, indicating the configuration is complete and enabled. diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 581d956d1f..c2340a763d 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.19.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `defender_safe_attachments_policy_enabled` check for M365 provider [(#9833)](https://github.com/prowler-cloud/prowler/pull/9833) +- `defender_safelinks_policy_enabled` check for M365 provider [(#9832)](https://github.com/prowler-cloud/prowler/pull/9832) +- AI Skills: Added a skill for creating new Attack Paths queries in openCypher, compatible with Neo4j and Neptune [(#9975)](https://github.com/prowler-cloud/prowler/pull/9975) +- `image` provider for container image scanning with Trivy integration [(#9984)](https://github.com/prowler-cloud/prowler/pull/9984) + +### 🔄 Changed + +- Update Azure Monitor service metadata to new format [(#9622)](https://github.com/prowler-cloud/prowler/pull/9622) + +## [5.18.2] (Prowler UNRELEASED) + +### 🐞 Fixed + +- `--repository` and `--organization` flags combined interaction in GitHub provider, qualifying unqualified repository names with organization [(#10001)](https://github.com/prowler-cloud/prowler/pull/10001) +- HPACK library logging tokens in debug mode for Azure, M365, and Cloudflare providers [(#10010)](https://github.com/prowler-cloud/prowler/pull/10010) + +--- + + ## [5.18.0] (Prowler v5.18.0) ### 🚀 Added @@ -9,6 +32,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `defender_zap_for_teams_enabled` check for M365 provider [(#9838)](https://github.com/prowler-cloud/prowler/pull/9838) - `compute_instance_suspended_without_persistent_disks` check for GCP provider [(#9747)](https://github.com/prowler-cloud/prowler/pull/9747) - `codebuild_project_webhook_filters_use_anchored_patterns` check for AWS provider to detect CodeBreach vulnerability [(#9840)](https://github.com/prowler-cloud/prowler/pull/9840) +- `defender_atp_safe_attachments_policy_enabled` check for M365 provider [(#9837)](https://github.com/prowler-cloud/prowler/pull/9837) - `exchange_shared_mailbox_sign_in_disabled` check for M365 provider [(#9828)](https://github.com/prowler-cloud/prowler/pull/9828) - CloudTrail Timeline abstraction for querying resource modification history [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101) - Cloudflare `--account-id` filter argument [(#9894)](https://github.com/prowler-cloud/prowler/pull/9894) @@ -17,6 +41,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `OpenStack` documentation for the support in the CLI [(#9848)](https://github.com/prowler-cloud/prowler/pull/9848) - Add HIPAA compliance framework for the Azure provider [(#9957)](https://github.com/prowler-cloud/prowler/pull/9957) - Cloudflare provider credentials as constructor parameters (`api_token`, `api_key`, `api_email`) [(#9907)](https://github.com/prowler-cloud/prowler/pull/9907) +- CIS 3.1 for the Oracle Cloud provider [(#9971)](https://github.com/prowler-cloud/prowler/pull/9971) ### 🔄 Changed @@ -36,7 +61,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update Azure Network service metadata to new format [(#9624)](https://github.com/prowler-cloud/prowler/pull/9624) - Update Azure Storage service metadata to new format [(#9628)](https://github.com/prowler-cloud/prowler/pull/9628) -### 🐛 Fixed +### 🐞 Fixed - Duplicated findings in `entra_user_with_vm_access_has_mfa` check when user has multiple VM access roles [(#9914)](https://github.com/prowler-cloud/prowler/pull/9914) - Jira integration failing with `INVALID_INPUT` error when sending findings with long resource UIDs exceeding 255-character summary limit [(#9926)](https://github.com/prowler-cloud/prowler/pull/9926) @@ -105,6 +130,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update Azure AI Search service metadata to new format [(#9087)](https://github.com/prowler-cloud/prowler/pull/9087) - Update Azure AKS service metadata to new format [(#9611)](https://github.com/prowler-cloud/prowler/pull/9611) - Update Azure API Management service metadata to new format [(#9612)](https://github.com/prowler-cloud/prowler/pull/9612) +- Enhance AWS IAM privilege escalation detection with patterns from pathfinding.cloud library [(#9922)](https://github.com/prowler-cloud/prowler/pull/9922) ### Fixed diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json index 156c17e26d..df9b6edfb5 100644 --- a/prowler/compliance/m365/cis_4.0_m365.json +++ b/prowler/compliance/m365/cis_4.0_m365.json @@ -318,7 +318,9 @@ { "Id": "2.1.1", "Description": "Enabling Safe Links policy for Office applications allows URL's that exist inside of Office documents and email applications opened by Office, Office Online and Office mobile to be processed against Defender for Office time-of-click verification and rewritten if required.**Note:** E5 Licensing includes a number of Built-in Protection policies. When auditing policies note which policy you are viewing, and keep in mind CIS recommendations often extend the Default or Build-in Policies provided by MS. In order to **Pass** the highest priority policy must match all settings recommended.", - "Checks": [], + "Checks": [ + "defender_safelinks_policy_enabled" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", @@ -385,7 +387,9 @@ { "Id": "2.1.4", "Description": "The Safe Attachments policy helps protect users from malware in email attachments by scanning attachments for viruses, malware, and other malicious content. When an email attachment is received by a user, Safe Attachments will scan the attachment in a secure environment and provide a verdict on whether the attachment is safe or not.", - "Checks": [], + "Checks": [ + "defender_safe_attachments_policy_enabled" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", @@ -406,7 +410,9 @@ { "Id": "2.1.5", "Description": "Safe Attachments for SharePoint, OneDrive, and Microsoft Teams scans these services for malicious files.", - "Checks": [], + "Checks": [ + "defender_atp_safe_attachments_and_docs_configured" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index 93fe2e2226..192e49dcfd 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -339,7 +339,9 @@ { "Id": "2.1.1", "Description": "Safe Links for Office Applications is a feature in Microsoft 365 that provides URL scanning and rewriting of inbound email messages in mail flow, and time-of-click verification of URLs and links in email messages, other Microsoft 365 applications such as Teams, and other locations such as SharePoint Online. It can help protect organizations from malicious links used in phishing and other attacks.", - "Checks": [], + "Checks": [ + "defender_safelinks_policy_enabled" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", @@ -404,7 +406,9 @@ { "Id": "2.1.4", "Description": "The Safe Attachments policy helps protect users from malware in email attachments by scanning attachments for viruses, malware, and other malicious content. When an email attachment is received by a user, Safe Attachments will scan the attachment in a secure environment and provide a verdict on whether the attachment is safe or not.", - "Checks": [], + "Checks": [ + "defender_safe_attachments_policy_enabled" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", @@ -425,7 +429,9 @@ { "Id": "2.1.5", "Description": "Safe Attachments for SharePoint, OneDrive, and Microsoft Teams scans these services for malicious files.", - "Checks": [], + "Checks": [ + "defender_atp_safe_attachments_and_docs_configured" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index 81ae9dea2a..3335fe0f19 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -115,6 +115,7 @@ "defender_antiphishing_policy_configured", "defender_antispam_outbound_policy_configured", "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_safelinks_policy_enabled", "defender_zap_for_teams_enabled", "entra_admin_users_phishing_resistant_mfa_enabled", "entra_identity_protection_sign_in_risk_enabled", @@ -692,6 +693,8 @@ "defender_malware_policy_common_attachments_filter_enabled", "defender_malware_policy_comprehensive_attachments_filter_applied", "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_safe_attachments_policy_enabled", + "defender_safelinks_policy_enabled", "defender_zap_for_teams_enabled", "teams_external_domains_restricted", "teams_external_users_cannot_start_conversations" @@ -729,6 +732,7 @@ ], "Checks": [ "defender_antiphishing_policy_configured", + "defender_safelinks_policy_enabled", "entra_admin_users_phishing_resistant_mfa_enabled" ] }, @@ -833,10 +837,11 @@ } ], "Checks": [ - "teams_external_domains_restricted", - "teams_external_users_cannot_start_conversations", + "defender_safelinks_policy_enabled", + "sharepoint_external_sharing_managed", "sharepoint_external_sharing_restricted", - "sharepoint_external_sharing_managed" + "teams_external_domains_restricted", + "teams_external_users_cannot_start_conversations" ] }, { diff --git a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json new file mode 100644 index 0000000000..080aa60476 --- /dev/null +++ b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json @@ -0,0 +1,1141 @@ +{ + "Framework": "CIS", + "Name": "CIS Oracle Cloud Infrastructure Foundations Benchmark v3.1.0", + "Version": "3.1", + "Provider": "OracleCloud", + "Description": "The CIS Oracle Cloud Infrastructure Foundations Benchmark provides prescriptive guidance for configuring security options for Oracle Cloud Infrastructure with an emphasis on foundational, testable, and architecture agnostic settings.", + "Requirements": [ + { + "Id": "1.1", + "Description": "Ensure service level admins are created to manage resources of particular service", + "Checks": [ + "identity_service_level_admins_exist" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "To apply least-privilege security principle, one can create service-level administrators in corresponding groups and assigning specific users to each service-level administrative group in a tenancy. This limits administrative access in a tenancy. It means service-level administrators can only manage resources of a specific service.Example policies for global/tenant level service-administrators```Allow group VolumeAdmins to manage volume-family in tenancyAllow group ComputeAdmins to manage instance-family in tenancyAllow group NetworkAdmins to manage virtual-network-family in tenancy``````A tenancy with identity domains : An Identity Domain is a container of users, groups, Apps and other security configurations. A tenancy that has Identity Domains available comes seeded with a 'Default' identity domain. If a group belongs to a domain different than the default domain, use a domain prefix in the policy statements.Example - Allow group / to in compartment If you do not include the before the , then the policy statement is evaluated as though the group belongs to the default identity domain.```Organizations have various ways of defining service-administrators. Some may prefer creating service administrators at a tenant level and some per department or per project or even per application environment ( dev/test/production etc.). Either approach works so long as the policies are written to limit access given to the service-administrators. Example policies for compartment level service-administrators ```Allow group NonProdComputeAdmins to manage instance-family in compartment devAllow group ProdComputeAdmins to manage instance-family in compartment productionAllow group A-Admins to manage instance-family in compartment Project-AAllow group A-Admins to manage volume-family in compartment Project-A``````A tenancy with identity domains : An Identity Domain is a container of users, groups, Apps and other security configurations. A tenancy that has Identity Domains available comes seeded with a 'Default' identity domain. If a group belongs to a domain different than the default domain, use a domain prefix in the policy statements.Example - Allow group / to in compartment If you do not include the before the , then the policy statement is evaluated as though the group belongs to the default identity domain.```", + "RationaleStatement": "Creating service-level administrators helps in tightly controlling access to Oracle Cloud Infrastructure (OCI) services to implement the least-privileged security principle.", + "ImpactStatement": "", + "RemediationProcedure": "Refer to the [policy syntax document](https://docs.cloud.oracle.com/en-us/iaas/Content/Identity/Concepts/policysyntax.htm) and create new policies if the audit results indicate that the required policies are missing.This can be done via OCI console or OCI CLI/SDK or API.Creating a new policy:***From CLI:***```oci iam policy create [OPTIONS]```Creates a new policy in the specified compartment (either the tenancy or another of your compartments). If you're new to policies, see [Getting Started with Policies](https://docs.cloud.oracle.com/Content/Identity/Concepts/policygetstarted.htm) You must specify a name for the policy, which must be unique across all policies in your tenancy and cannot be changed.You must also specify a description for the policy (although it can be an empty string). It does not have to be unique, and you can change it anytime with UpdatePolicy.You must specify one or more policy statements in the statements array.For information about writing policies, see How [Policies Work](https://docs.cloud.oracle.com/Content/Identity/Concepts/policies.htm) and [Common Policies](https://docs.cloud.oracle.com/Content/Identity/Concepts/commonpolicies.htm).", + "AuditProcedure": "***From CLI:***1) [Set up OCI CLI](https://docs.cloud.oracle.com/iaas/Content/API/SDKDocs/cliinstall.htm) with an IAM administrator user who has read access to IAM resources such as groups and policies.2) Run OCI CLI command providing the root_compartment_OCIDGet the list of groups in a tenancy```oci iam group list --compartment-id | grep name``````A tenancy with identity domains : The above CLI commands work with the default identity domain only.For IaaS resource management, users and groups created in the default domain are sufficient. ```3) Ensure distinct administrative groups are created as per your organization's definition of service-administrators.4) Verify the appropriate policies are created for the service-administrators groups to have the right access to the corresponding services. Retrieve the policy statements scoped at the tenancy level and/or per compartment. ```oci iam policy list --compartment-id | grep in tenancyoci iam policy list --compartment-id | grep in compartment```The --compartment-id parameter can be changed to a child compartment to get policies associated with child compartments.```oci iam policy list --compartment-id | grep in compartment```Verify the results to ensure the right policies are created for service-administrators to have the necessary access.", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "1.2", + "Description": "Ensure permissions on all resources are given only to the tenancy administrator group", + "Checks": [ + "identity_tenancy_admin_permissions_limited" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "There is a built-in OCI IAM policy enabling the Administrators group to perform any action within a tenancy. In the OCI IAM console, this policy reads:```Allow group Administrators to manage all-resources in tenancy```Administrators create more users, groups, and policies to provide appropriate access to other groups.Administrators should not allow any-other-group full access to the tenancy by writing a policy like this - ```Allow group any-other-group to manage all-resources in tenancy```The access should be narrowed down to ensure the least-privileged principle is applied.", + "RationaleStatement": "Permission to manage all resources in a tenancy should be limited to a small number of users in the `Administrators` group for break-glass situations and to set up users/groups/policies when a tenancy is created.No group other than `Administrators` in a tenancy should need access to all resources in a tenancy, as this violates the enforcement of the least privilege principle.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1) Login to OCI console.2) Go to `Identity` -> `Policies`, In the compartment dropdown, choose the root compartment. Open each policy to view the policy statements. 2) Remove any policy statement that allows any group other than `Administrators` or any service access to manage all resources in the tenancy. **From CLI:**The policies can also be updated via OCI CLI, SDK and API, with an example of the CLI commands below: * Delete a policy via the CLI: `oci iam policy delete --policy-id ` * Update a policy via the CLI: `oci iam policy update --policy-id --statements `Note: You should generally **not** delete the policy that allows the `Administrators` group the ability to manage all resources in the tenancy.", + "AuditProcedure": "**From CLI:**1) Run OCI CLI command providing the root compartment OCID to get the list of groups having access to manage all resources in your tenancy. ```oci iam policy list --compartment-id | grep -i to manage all-resources in tenancy ```2) Verify the results to ensure only the `Administrators` group has access to manage all resources in tenancy. Allow group Administrators to manage all-resources in tenancy", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "1.3", + "Description": "Ensure IAM administrators cannot update tenancy Administrators group", + "Checks": [ + "identity_iam_admins_cannot_update_tenancy_admins" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Tenancy administrators can create more users, groups, and policies to provide other service administrators access to OCI resources.For example, an IAM administrator will need to have access to manage resources like compartments, users, groups, dynamic-groups, policies, identity-providers, tenancy tag-namespaces, tag-definitions in the tenancy.The policy that gives IAM-Administrators or any other group full access to 'groups' resources should not allow access to the tenancy 'Administrators' group.The policy statements would look like -```Allow group IAMAdmins to inspect users in tenancyAllow group IAMAdmins to use users in tenancy where target.group.name != 'Administrators'Allow group IAMAdmins to inspect groups in tenancyAllow group IAMAdmins to use groups in tenancy where target.group.name != 'Administrators'```**Note:** You must include separate statements for 'inspect' access, because the target.group.name variable is not used by the ListUsers and ListGroups operations", + "RationaleStatement": "These policy statements ensure that no other group can manage tenancy administrator users or the membership to the 'Administrators' group thereby gain or remove tenancy administrator access.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.2. Select `Identity` from Services Menu.3. Select `Policies` from Identity Menu.4. Click on an individual policy under the Name heading.5. Ensure Policy statements look like this -```Allow group IAMAdmins to use users in tenancy where target.group.name != 'Administrators'Allow group IAMAdmins to use groups in tenancy where target.group.name != 'Administrators'```", + "AuditProcedure": "**From CLI:**1) Run the following OCI CLI commands providing the root_compartment_OCID ```oci iam policy list --compartment-id | grep -i to use users in tenancyoci iam policy list --compartment-id | grep -i to use groups in tenancy```2) Verify the results to ensure that the policy statements that grant access to use or manage users or groups in the tenancy have a condition that excludes access to `Administrators` group or to users in the Administrators group.", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "1.4", + "Description": "Ensure IAM password policy requires minimum length of 14 or greater", + "Checks": [ + "identity_password_policy_minimum_length_14" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Password policies are used to enforce password complexity requirements. IAM password policies can be used to ensure passwords are at least a certain length and are composed of certain characters. It is recommended the password policy require a minimum password length 14 characters and contain 1 non-alphabeticcharacter (Number or “Special Character”).", + "RationaleStatement": "In keeping with the overall goal of having users create a password that is not overly weak, an eight-character minimum password length is recommended for an MFA account, and 14 characters for a password only account. In addition, maximum password length should be made as long as possible based on system/software capabilities and not restricted by policy.In general, it is true that longer passwords are better (harder to crack), but it is also true that forced password length requirements can cause user behavior that is predictable and undesirable. For example, requiring users to have a minimum 16-character password may cause them to choose repeating patterns like fourfourfourfour or passwordpassword that meet the requirement but aren’t hard to guess. Additionally, length requirements increase the chances that users will adopt other insecure practices, like writing them down, re-using them or storing them unencrypted in their documents. Password composition requirements are a poor defense against guessing attacks. Forcing users to choose some combination of upper-case, lower-case, numbers, and special characters has a negative impact. It places an extra burden on users and manywill use predictable patterns (for example, a capital letter in the first position, followed by lowercase letters, then one or two numbers, and a “special character” at the end). Attackers know this, so dictionary attacks will often contain these common patterns and use the most common substitutions like, $ for s, @ for a, 1 for l, 0 for o.Passwords that are too complex in nature make it harder for users to remember, leading to bad practices. In addition, composition requirements provide no defense against common attack types such as social engineering or insecure storage of passwords.", + "ImpactStatement": "", + "RemediationProcedure": "1. Go to Identity Domains: [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select the Compartment the Domain to remediate is in1. Click on the Domain to remediate1. Click on Settings1. Click on Password policy to remediate1. Click Edit password rules1. Update the `Password length (minimum)` setting to 14 or greater6. Under The `Passwords must meet the following character requirements` section, update the number given in `Special (minimum)` setting to `1` or greateror Under The `Passwords must meet the following character requirements` section, update the number given in `Numeric (minimum)` setting to `1` or greater7. Click `Save changes`", + "AuditProcedure": "1. Go to Identity Domains: [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select the `Compartment` your Domain to review is in1. Click on the Domain to review1. Click on `Settings`1. Click on `Password policy`1. Click each Password policy in the domain1. Ensure `Password length (minimum)` is greater than or equal to 141. Under The `The following criteria apply to passwords` section, ensure that the number given in `Numeric (minimum)` setting is `1`, or the `Special (minimum)` setting is `1`.The following criteria apply to passwords:6. Ensure that 1 or more is selected for `Numeric (minimum)` OR `Special (minimum)`**From Cloud Guard:**To Enable Cloud Guard Auditing:Ensure Cloud Guard is enabled in the root compartment of the tenancy. For more information about enabling Cloud Guard, please look at the instructions included in Ensure Cloud Guard is enabled in the root compartment of the tenancy Recommendation in the Logging and Monitoring section. **From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console.2. Click `Cloud Guard` from the “Services” submenu.3. Click `Detector Recipes` in the Cloud Guard menu.4. Click `OCI Configuration Detector Recipe (Oracle Managed)` under the Recipe Name column.5. Find Password policy does not meet complexity requirements in the Detector Rules column.6. Select the vertical ellipsis icon and chose `Edit` on the Password policy does not meet complexity requirements row.7. In the Edit Detector Rule window, find the Input Setting box and verify/change the Required password length setting to 14.8. Click the `Save` button.**From CLI:**1. Update the Password policy does not meet complexity requirements Detector Rule in Cloud Guard to generate Problems if IAM password policy isn’t configured to enforce a password length of at least 14 characters with the following command:```oci cloud-guard detector-recipe-detector-rule update --detector-recipe-id --detector-rule-id PASSWORD_POLICY_NOT_COMPLEX --details '{configurations:[{ configKey : passwordPolicyMinLength, name : Required password length, value : 14, dataType : null, values : null }]}'```", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "https://www.cisecurity.org/white-papers/cis-password-policy-guide/" + } + ] + }, + { + "Id": "1.5", + "Description": "Ensure IAM password policy expires passwords within 365 days", + "Checks": [ + "identity_password_policy_expires_within_365_days" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "IAM password policies can require passwords to be rotated or expired after a given number of days. It is recommended that the password policy expire passwords after 365 and are changed immediately based on events.", + "RationaleStatement": "Excessive password expiration requirements do more harm than good, because these requirements make users select predictable passwords, composed of sequential words and numbers that are closely related to each other. In these cases, the next password can be predicted based on the previous one (incrementing a number used in the password for example). Also, password expiration requirements offer no containment benefits because attackers will often use credentials as soon as they compromise them. Instead, immediate password changes should be based on key events including, but notlimited to:1. Indication of compromise1. Change of user roles1. When a user leaves the organization.Not only does changing passwords every few weeks or months frustrate the user, it's been suggested that it does more harm than good, because it could lead to bad practices by the user such as adding a character to the end of their existing password.In addition, we also recommend a yearly password change. This is primarily because for all their good intentions users will share credentials across accounts. Therefore, even if a breach is publicly identified, the user may not see this notification, or forget they have an account on that site. This could leave a shared credential vulnerable indefinitely. Having an organizational policy of a 1-year (annual) password expiration is a reasonable compromise to mitigate this with minimal user burden.", + "ImpactStatement": "", + "RemediationProcedure": "1. Go to Identity Domains: [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select the `Compartment` the Domain to remediate is in1. Click on the Domain to remediate1. Click on `Settings`1. Click on `Password policy` to remediate1. Click `Edit password rules`1. Change `Expires after (days)` to 365", + "AuditProcedure": "1. Go to Identity Domains: [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select the `Compartment` your Domain to review is in1. Click on the Domain to review1. Click on `Settings`1. Click on `Password policy`1. Click each Password policy in the domain1. Ensure `Expires after (days)` is less than or equal to 365 days", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "https://www.cisecurity.org/white-papers/cis-password-policy-guide/" + } + ] + }, + { + "Id": "1.6", + "Description": "Ensure IAM password policy prevents password reuse", + "Checks": [ + "identity_password_policy_prevents_reuse" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "IAM password policies can prevent the reuse of a given password by the same user. It is recommended the password policy prevent the reuse of passwords.", + "RationaleStatement": "Enforcing password history ensures that passwords are not reused in for a certain period of time by the same user. If a user is not allowed to use last 24 passwords, that window of time is greater. This helps maintain the effectiveness of password security.", + "ImpactStatement": "", + "RemediationProcedure": "1. Go to Identity Domains: [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select the Compartment the Domain to remediate is in1. Click on the Domain to remediate1. Click on Settings1. Click on Password policy to remediate1. Click Edit password rules1. Update the number of remembered passwords in `Previous passwords remembered` setting to 24 or greater.", + "AuditProcedure": "1. Go to Identity Domains: [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select the `Compartment` your Domain to review is in1. Click on the Domain to review1. Click on `Settings`1. Click on `Password policy`1. Click each Password policy in the domain1. Ensure `Previous passwords remembered` is set 24 or greater", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "" + } + ] + }, + { + "Id": "1.7", + "Description": "Ensure MFA is enabled for all users with a console password", + "Checks": [ + "identity_user_mfa_enabled_console_access" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Multi-factor authentication is a method of authentication that requires the use of more than one factor to verify a user’s identity.With MFA enabled in the IAM service, when a user signs in to Oracle Cloud Infrastructure, they are prompted for their user name and password, which is the first factor (something that they know). The user is then prompted to provide a verification code from a registered MFA device, which is the second factor (something that they have). The two factors work together, requiring an extra layer of security to verify the user’s identity and complete the sign-in process.OCI IAM supports two-factor authentication using a password (first factor) and a device that can generate a time-based one-time password (TOTP) (second factor).See [OCI documentation](https://docs.cloud.oracle.com/en-us/iaas/Content/Identity/Tasks/usingmfa.htm) for more details.", + "RationaleStatement": "Multi factor authentication adds an extra layer of security during the login process and makes it harder for unauthorized users to gain access to OCI resources.", + "ImpactStatement": "", + "RemediationProcedure": "Each user must enable MFA for themselves using a device they will have access to every time they sign in. An administrator cannot enable MFA for another user but can enforce MFA by identifying the list of non-complaint users, notifying them or disabling access by resetting the password for non-complaint accounts.**Disabling access from Console:**1. Go to [https://cloud.oracle.com/identity/](https://cloud.oracle.com/identity/).1. Select `Domains` from Identity menu.1. Select the domain1. Click `Security`1. Click `Sign-on polices` then the `Default Sign-on Policy`1. Under the sign-on rules header, click the three dots on the rule with the highest priority.1. Select `Edit sign-on rule`1. Make a change to ensure that `allow access` is selected and `prompt for an additional factor` is enabled", + "AuditProcedure": "**From Console:**1. Go to Identity Domains: [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select the `Compartment` your Domain to review is in1. Click on the Domain to review1. Click on `Security`1. Click `Sign-on policies` 1. Select the sign-on policy to review6. Under the sign-on rules header, click the three dots on the rule with the highest priority.7. Select `Edit sign-on rule`8. Verify that `allow access` is selected and `prompt for an additional factor` is enabled* This requires users to enable MFA when they next login next however, to determine users have enabled MFA use the below CLI.**From the CLI:*** This CLI command checks which users have enabled MFA for their accounts1. Execute the below:```tenancy_ocid=`oci iam compartment list --raw-output --query data[?contains(\\compartment-id\\,'.tenancy.')].\\compartment-id\\ | [0]`for id_domain_url in `oci iam domain list --compartment-id $tenancy_ocid --all | jq -r '.data[] | .url'`do oci identity-domains users list --endpoint $id_domain_url 2>/dev/null | jq -r '.data.resources[] | select(.urn-ietf-params-scim-schemas-oracle-idcs-extension-mfa-user.mfa-status!=ENROLLED)' 2>/dev/null | jq -r '.ocid'donefor region in `oci iam region-subscription list | jq -r '.data[] | .region-name'`; do for compid in `oci iam compartment list --compartment-id-in-subtree TRUE --all 2>/dev/null | jq -r '.data[] | .id'` do for id_domain_url in `oci iam domain list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | .url'` do oci identity-domains users list --endpoint $id_domain_url 2>/dev/null | jq -r '.data.resources[] | select(.urn-ietf-params-scim-schemas-oracle-idcs-extension-mfa-user.mfa-status!=ENROLLED)' 2>/dev/null | jq -r '.ocid' done done done```2. Ensure no results are returned", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "https://docs.cloud.oracle.com/en-us/iaas/Content/Identity/Tasks/usingmfa.htm:https://docs.oracle.com/en-us/iaas/Content/Security/Reference/iam_security_topic-IAM_MFA.htm" + } + ] + }, + { + "Id": "1.8", + "Description": "Ensure user API keys rotate within 90 days", + "Checks": [ + "identity_user_api_keys_rotated_90_days" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "API keys are used by administrators, developers, services and scripts for accessing OCI APIs directly or via SDKs/OCI CLI to search, create, update or delete OCI resources.The API key is an RSA key pair. The private key is used for signing the API requests and the public key is associated with a local or synchronized user's profile.", + "RationaleStatement": "It is important to secure and rotate an API key every 90 days or less as it provides the same level of access that a user it is associated with has.In addition to a security engineering best practice, this is also a compliance requirement. For example, PCI-DSS Section 3.6.4 states, Verify that key-management procedures include a defined cryptoperiod for each key type in use and define a process for key changes at the end of the defined crypto period(s).", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.2. Select `Identity & Security` from the Services menu.3. Select `Domains` from the Identity menu.4. For each domain listed, click on the name and select `Users`.5. Click on an individual user under the Name heading.6. Click on `API Keys` in the lower left-hand corner of the page.7. Delete any API Keys that are older than 90 days under the `Created` column of the API Key table.**From CLI:**```oci iam user api-key delete --user-id __ --fingerprint ```", + "AuditProcedure": "**From Console:**1. Login to OCI Console.2. Select `Identity & Security` from the Services menu.3. Select `Domains` from the Identity menu.4. For each domain listed, click on the name and select `Users`.5. Click on an individual user under the Name heading.6. Click on `API Keys` in the lower left-hand corner of the page.7. Ensure the date of the API key under the `Created` column of the API Key is no more than 90 days old.", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "" + } + ] + }, + { + "Id": "1.9", + "Description": "Ensure user customer secret keys rotate within 90 days", + "Checks": [ + "identity_user_customer_secret_keys_rotated_90_days" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Object Storage provides an API to enable interoperability with Amazon S3. To use this Amazon S3 Compatibility API, you need to generate the signing key required to authenticate with Amazon S3.This special signing key is an Access Key/Secret Key pair. Oracle generates the Customer Secret key to pair with the Access Key.", + "RationaleStatement": "It is important to rotate customer secret keys at least every 90 days, as they provide the same level of object storage access that the user they are associated with has.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.1. Select `Identity & Security` from the Services menu.1. Select Domains from the Identity menu.1. For each domain listed, click on the name and select `Users`.1. Click on an individual user under the `Username` heading.1. Click on `Customer Secret Keys` in the lower left-hand corner of the page.1. Delete any Access Keys with a date older than 90 days under the `Created` column of the Customer Secret Keys.", + "AuditProcedure": "**From Console:**1. Login to OCI Console.1. Select `Identity & Security` from the Services menu.1. Select Domains from the Identity menu.1. For each domain listed, click on the name and select `Users`.1. Click on an individual user under the `Username` heading.1. Click on `Customer Secret Keys` in the lower left-hand corner of the page.1. Ensure the date of the Customer Secret Key under the `Created` column of the Customer Secret Key is no more than 90 days old.", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "" + } + ] + }, + { + "Id": "1.10", + "Description": "Ensure user auth tokens rotate within 90 days", + "Checks": [ + "identity_user_auth_tokens_rotated_90_days" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Auth tokens are authentication tokens generated by Oracle. You use auth tokens to authenticate with APIs that do not support the Oracle Cloud Infrastructure signature-based authentication. If the service requires an auth token, the service-specific documentation instructs you to generate one and how to use it.", + "RationaleStatement": "It is important to secure and rotate an auth token every 90 days or less as it provides the same level of access to APIs that do not support the OCI signature-based authentication as the user associated to it.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.1. Select `Identity & Security` from the Services menu.1. Select Domains from the Identity menu.1. For each domain listed, click on the name and select `Users`.1. Click on an individual user under the `Username` heading.1. Click on `Auth Tokens` in the lower left-hand corner of the page.1. Delete any auth token with a date older than 90 days under the `Created` column of the Customer Secret Keys.", + "AuditProcedure": "**From Console:**1. Login to OCI Console.1. Select `Identity & Security` from the Services menu.1. Select Domains from the Identity menu.1. For each domain listed, click on the name and select `Users`.1. Click on an individual user under the `Username` heading.5. Click on `Auth Tokens` in the lower left-hand corner of the page.1. Ensure the date of the Auth Token under the `Created` column of the Customer Secret Key is no more than 90 days old.", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "" + } + ] + }, + { + "Id": "1.11", + "Description": "Ensure user IAM Database Passwords rotate within 90 days", + "Checks": [ + "identity_user_db_passwords_rotated_90_days" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users can create and manage their database password in their IAM user profile and use that password to authenticate to databases in their tenancy. An IAM database password is a different password than an OCI Console password. Setting an IAM database password allows an authorized IAM user to sign in to one or more Autonomous Databases in their tenancy.An IAM database password is a different password than an OCI Console password. Setting an IAM database password allows an authorized IAM user to sign in to one or more Autonomous Databases in their tenancy.", + "RationaleStatement": "It is important to secure and rotate an IAM Database password 90 days or less as it provides the same access the user would have a using a local database user.", + "ImpactStatement": "", + "RemediationProcedure": "#### OCI IAM with Identity Domains**From Console:**1. Login to OCI Console.1. Select `Identity & Security` from the Services menu.1. Select Domains from the Identity menu.1. For each domain listed, click on the name and select `Users`.1. Click on an individual user under the `Username` heading.1. Click on `IAM Database Passwords` in the lower left-hand corner of the page.1. Delete any Database Passwords with a date older than 90 days under the `Created` column of the Database Passwords.", + "AuditProcedure": "**From Console:**1. Login to OCI Console.2. Select `Identity & Security` from the Services menu.3. Select `Users` from the Identity menu.4. Click on an individual user under the Name heading.5. Click on `Database Passwords` in the lower left-hand corner of the page.6. Ensure the date of the Database Passwords under the `Created` column of the Database Passwords is no more than 90 days **From Console:**1. Login to OCI Console.1. Select `Identity & Security` from the Services menu.1. Select Domains from the Identity menu.1. For each domain listed, click on the name and select `Users`.1. Click on an individual user under the `Username` heading.1. Click on `Database Passwords` in the lower left-hand corner of the page.1. Ensure the date of the Database Passwords under the `Created` column of the Database Password is no more than 90 days old.", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "https://docs.oracle.com/en-us/iaas/Content/Identity/Concepts/usercredentials.htm#usercredentials_iam_db_pwd" + } + ] + }, + { + "Id": "1.12", + "Description": "Ensure API keys are not created for tenancy administrator users", + "Checks": [ + "identity_tenancy_admin_users_no_api_keys" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Tenancy administrator users have full access to the organization's OCI tenancy. API keys associated with user accounts are used for invoking the OCI APIs via custom programs or clients like CLI/SDKs. The clients are typically used for performing day-to-day operations and should never require full tenancy access. Service-level administrative users with API keys should be used instead.", + "RationaleStatement": "For performing day-to-day operations tenancy administrator access is not needed.Service-level administrative users with API keys should be used to apply privileged security principle.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login to OCI console.2. Select `Identity` from Services menu.3. Select `Users` from Identity menu, or select `Domains`, select a domain, and select `Users`.4. Select the username of a tenancy administrator user with an API key.5. Select `API Keys` from the menu in the lower left-hand corner.6. Delete any associated keys from the `API Keys` table.7. Repeat steps 3-6 for all tenancy administrator users with an API key.**From CLI:**1. For each tenancy administrator user with an API key, execute the following command to retrieve API key details:```oci iam user api-key list --user-id ```2. For each API key, execute the following command to delete the key:```oci iam user api-key delete --user-id --fingerprint ```3. The following message will be displayed:```Are you sure you want to delete this resource? [y/N]:```4. Type 'y' and press 'Enter'.", + "AuditProcedure": "**From Console:**1. Login to OCI Console. 1. Select `Identity & Security` from the Services menu.1. Select `Domains` from the Identity menu.1. Click on the 'Default' Domain in the (root).1. Click on 'Groups'.1. Select the 'Administrators' group by clicking on the Name1. Click on each local or synchronized `Administrators` member profile4. Click on API Keys to verify if a user has an API key associated.", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "" + } + ] + }, + { + "Id": "1.13", + "Description": "Ensure all OCI IAM local user accounts have a valid and current email address", + "Checks": [ + "identity_user_valid_email_address" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "All OCI IAM local user accounts have an email address field associated with the account. It is recommended to specify an email address that is valid and current.If you have an email address in your user profile, you can use the Forgot Password link on the sign on page to have a temporary password sent to you.", + "RationaleStatement": "Having a valid and current email address associated with an OCI IAM local user account allows you to tie the account to identity in your organization. It also allows that user to reset their password if it is forgotten or lost.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.1. Select `Identity & Security` from the Services menu.1. Select Domains from the Identity menu.1. For each domain listed, click on the name and select `Users`.1. Click on each non-complaint user.1. Click on `Edit User`.1. Enter a valid and current email address in the Email and Recovery Email text boxes.1. Click `Save Changes`", + "AuditProcedure": "**From Console:**1. Login to OCI Console.1. Select `Identity & Security` from the Services menu.1. Select Domains from the Identity menu.1. For each domain listed, click on the name and select `Users`.1. Click on an individual user under the `Username` heading.1. Ensure a valid and current email address is next to Email and Recovery email.", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "" + } + ] + }, + { + "Id": "1.14", + "Description": "Ensure Instance Principal authentication is used for OCI instances, OCI Cloud Databases and OCI Functions to access OCI resources", + "Checks": [ + "identity_instance_principal_used" + ], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "OCI instances, OCI database and OCI functions can access other OCI resources either via an OCI API key associated to a user or via Instance Principal. Instance Principal authentication can be achieved by inclusion in a Dynamic Group that has an IAM policy granting it the required access or using an OCI IAM policy that has `request.principal` added to the `where` clause. Access to OCI Resources refers to making API calls to another OCI resource like Object Storage, OCI Vaults, etc.", + "RationaleStatement": "Instance Principal reduces the risks related to hard-coded credentials. Hard-coded API keys can be shared and require rotation, which can open them up to being compromised. Compromised credentials could allow access to OCI services outside of the expected radius.", + "ImpactStatement": "For an OCI instance that contains embedded credential audit the scripts and environment variables to ensure that none of them contain OCI API Keys or credentials.", + "RemediationProcedure": "**From Console (Dynamic Groups):**1. Go to [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select a Compartment1. Click on the Domain1. Click on `Dynamic groups`1. Click Create Dynamic Group.1. Enter a Name1. Enter a Description1. Enter Matching Rules to that includes the instances accessing your OCI resources.1. Click Create.", + "AuditProcedure": "**From Console (Dynamic Groups):**1. Go to [https://cloud.oracle.com/identity/domains/](https://cloud.oracle.com/identity/domains/)1. Select a Compartment1. Click on a Domain1. Click on `Dynamic groups`1. Click on the Dynamic Group1. Check if the Matching Rules includes the instances accessing your OCI resources.**From Console (request.principal):**1. Go to [https://cloud.oracle.com/identity/policies](https://cloud.oracle.com/identity/policies)1. Select a Compartment1. Click on an individual policy under the Name heading.1. Ensure Policy statements look like this :```allow any-user to in compartment where ALL {request.principal.type='', request.principal.id=''}```or```allow any-user to in compartment where ALL {request.principal.type='', request.principal.compartment.id=''}```**From CLI (request.principal):**1. Execute the following for each compartment_OCID: ```oci iam policy list --compartment-id | grep request.principal```1. Ensure that the condition includes the instances accessing your OCI resources", + "AdditionalInformation": "The Audit Procedure and Remediation Procedure for OCI IAM without Identity Domains can be found in the CIS OCI Foundation Benchmark 2.0.0 under the respective recommendations.", + "References": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingdynamicgroups.htm" + } + ] + }, + { + "Id": "1.15", + "Description": "Ensure storage service-level admins cannot delete resources they manage", + "Checks": [], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "To apply the separation of duties security principle, one can restrict service-level administrators from being able to delete resources they are managing. It means service-level administrators can only manage resources of a specific service but not delete resources for that specific service.Example policies for global/tenant level for block volume service-administrators:```Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE' Allow group VolumeUsers to manage volume-backups in tenancy where request.permission!='VOLUME_BACKUP_DELETE'```Example policies for global/tenant level for file storage system service-administrators:```Allow group FileUsers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'Allow group FileUsers to manage mount-targets in tenancy where request.permission!='MOUNT_TARGET_DELETE'Allow group FileUsers to manage export-sets in tenancy where request.permission!='EXPORT_SET_DELETE'```Example policies for global/tenant level for object storage system service-administrators:```Allow group BucketUsers to manage objects in tenancy where request.permission!='OBJECT_DELETE' Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'```", + "RationaleStatement": "Creating service-level administrators without the ability to delete the resource they are managing helps in tightly controlling access to Oracle Cloud Infrastructure (OCI) services by implementing the separation of duties security principle.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login to OCI console.2. Go to Identity -> Policies, In the compartment dropdown, choose the compartment. Open each policy to view the policy statements.3. Add the appropriate `where` condition to any policy statement that allows the storage service-level to manage the storage service.", + "AuditProcedure": "**From Console:**1. Login to OCI console.2. Go to Identity -> Policies, In the compartment dropdown, choose the compartment. 3. Open each policy to view the policy statements.4. Verify the policies to ensure that the policy statements that grant access to storage service-level administrators have a condition that excludes access to delete the service they are the administrator for.**From CLI:**1. Execute the following command:```for compid in `oci iam compartment list --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id'` do for policy in `oci iam policy list --compartment-id $compid 2>/dev/null | jq -r '.data[] | .id'` do output=`oci iam policy list --compartment-id $compid 2>/dev/null | jq -r '.data[] | .id, .name, .statements'` if [ ! -z $output ]; then echo $output; fi done done```2. Verify the policies to ensure that the policy statements that grant access to storage service-level administrators have a condition that excludes access to delete the service they are the administrator for.", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en/solutions/oci-best-practices/protect-data-rest1.html#GUID-939A5EA1-3057-48E0-9E02-ADAFCB82BA3E:https://docs.oracle.com/en-us/iaas/Content/Identity/policyreference/policyreference.htm:https://docs.oracle.com/en-us/iaas/Content/Block/home.htm:https://docs.oracle.com/en-us/iaas/Content/File/home.htm:https://docs.oracle.com/en-us/iaas/Content/Object/home.htm" + } + ] + }, + { + "Id": "1.16", + "Description": "Ensure OCI IAM credentials unused for 45 days or more are disabled", + "Checks": [], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "OCI IAM Local users can access OCI resources using different credentials, such as passwords or API keys. It is recommended that credentials that have been unused for 45 days or more be deactivated or removed.", + "RationaleStatement": "Disabling or removing unnecessary OCI IAM local users will reduce the window of opportunity for credentials associated with a compromised or abandoned account to be used.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.2. Select `Identity & Security` from the Services menu.3. Select Domains from the Identity menu.4. For each domain listed, click on the name and select `Users`.5. Click on an individual user under the `Username` heading.6. Click `More action`7. Select `Deactivate`**From CLI:**1. Create a input.json:```{ operations: [ { op: replace, path: active,value: false} ], schemas: [urn:ietf:params:scim:api:messages:2.0:PatchOp], userId: }```2. Execute the below:```oci identity-domains user patch --from-json file://file.json --endpoint ```", + "AuditProcedure": "Perform the following to determine if unused credentials exist:**From Console:**For Passwords:1. Login to OCI Console.2. Select `Identity & Security` from the Services menu.3. Select `Domains` from the `Identity` menu.4. For each domain listed, click on the name 5. Click `Reports`6. Under Dormant users report click `View report`7. Enter a date 45 days from today’s date in Last Successful Login Date8. Check and ensure that `Last Successful Login Date` is greater than 45 days or emptyFor API Keys:1. Login to OCI Console.2. Select `Observability & Management` from the Services menu.3. Select `Search` from `Logging` menu4. Click `Show Advanced Mode` in the right corner5. Select `Custom` from `Filter by time`6. Under `Select regions to search` add regions7. Under `Query` enter the following query in the text box:```search /_Audit_Include_Subcompartment | data.identity.credentials='//' | summarize count() by data.identity.principalId```8. Enter a day range - Note each query can only be 14 days multiple queries will be required to go 45 days9. Click `Search`10. Expand the results11. If results the count is not zero the user has used their API key during that period12. Repeat steps 8 – 11 for the 45-day period**From CLI:**For Passwords:1. Execute the below:```oci identity-domains users list --all --endpoint --attributes urn:ietf:params:scim:schemas:oracle:idcs:extension:userState:User:lastSuccessfulLoginDate --profile Oracle --query '.data.resources[]|.user-name + + .urn-ietf-params-scim-schemas-oracle-idcs-extension-user-state-user.last-successful-login-date'```2. Review the output the that the date is under 45 days, or no date means they have not logged inFor API Keys: 1. Create the search query text:```export query=search \\/_Audit_Include_Subcompartment\\ | data.identity.credentials='*' | summarize count() by data.identity.principalId```2. Select a day range. Date format is `2024-12-01`- Note each query can only be 14 days multiple queries will be required to go 45 days3. Execute the below:```oci logging-search search-logs --search-query $query --time-start --time-end --query 'data.results[0].data.count' export query=search \\/_Audit_Include_Subcompartment\\ | data.identity.credentials='*' | summarize count() by data.identity.principalId```4. If results the count is not zero, the user has used their API key during that period5. Repeat steps 2 – 4 for the 45-day period", + "AdditionalInformation": "This audit should exclude the OCI Administrator, break-glass accounts, and service accounts as these accounts should only be used for day-to-day business and would likely be unused for up to 45 days.", + "References": "" + } + ] + }, + { + "Id": "1.17", + "Description": "Ensure there is only one active API Key for any single OCI IAM user", + "Checks": [], + "Attributes": [ + { + "Section": "1. Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "API Keys are long-term credentials for an OCI IAM user. They can be used to make programmatic requests to the OCI APIs directly or via, OCI SDKs or the OCI CLI.", + "RationaleStatement": "Having a single API Key for an OCI IAM reduces attack surface area and makes it easier to manage.", + "ImpactStatement": "Deletion of an OCI API Key will remove programmatic access to OCI APIs", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.2. Select `Identity & Security` from the Services menu.3. Select `Domains` from the Identity menu.4. For each domain listed, click on the name and select Users.5. Click on an individual user under the Name heading.6. Click on `API Keys` in the lower left-hand corner of the page.7. Delete one of the API Keys **From CLI:**1. Follow the audit procedure above.2. For API Key ID to be removed execute the following command:```oci identity-domains api-key delete –api-key-id --endpoint ```", + "AuditProcedure": "**From Console:**1. Login to OCI Console.2. Select `Identity & Security` from the Services menu.3. Select `Users` from the Identity menu.4. Click on an individual user under the Name heading.5. Click on `API Keys` in the lower left-hand corner of the page.6. Ensure the has only has a one API Key**From CLI:**1. Each user and in each Identity Domain```oci raw-request --http-method GET --target-uri https:///admin/v1/ApiKeys?filter=user.ocid+eq+%%22 | jq '.data.Resources[] | \\(.fingerprint) \\(.id)'```2. Ensure only one key is returned", + "AdditionalInformation": "", + "References": "https://docs.public.oneportal.content.oci.oraclecloud.com/en-us/iaas/Content/Security/Reference/iam_security_topic-IAM_Credentials.htm#IAM_Credentials" + } + ] + }, + { + "Id": "2.1", + "Description": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 22", + "Checks": [ + "network_security_list_ingress_from_internet_to_ssh_port" + ], + "Attributes": [ + { + "Section": "2. Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Security lists provide stateful and stateless filtering of ingress and egress network traffic to OCI resources on a subnet level. It is recommended that no security list allows unrestricted ingress access to port 22.", + "RationaleStatement": "Removing unfettered connectivity to remote console services, such as Secure Shell (SSH), reduces a server's exposure to risk.", + "ImpactStatement": "For updating an existing environment, care should be taken to ensure that administrators currently relying on an existing ingress from 0.0.0.0/0 have access to ports 22 and/or 3389 through another network security group or security list.", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above.2. For each security list in the returned results, click the security list name3. Either edit the `ingress rule` to be more restrictive, delete the `ingress rule` or click on the `VCN` and terminate the `security list` as appropriate.**From CLI:**1. Follow the audit procedure.2. For each of the `security lists` identified, execute the following command:```oci network security-list get --security-list-id ```3. Then either: - Update the `security list` by copying the `ingress-security-rules` element from the JSON returned by the above command, edit it appropriately and use it in the following command:```oci network security-list update --security-list-id --ingress-security-rules ''``` or - Delete the security list with the following command:```oci network security-list delete --security-list-id ```", + "AuditProcedure": "**From Console:**1. Login to the OCI Console.2. Click the search bar at the top of the screen.3. Type `Advanced Resource Query` and hit `enter`.4. Click the `Advanced Resource Query` button in the upper right corner of the screen.5. Enter the following query in the query box:```query SecurityList resources where (IngressSecurityRules.source = '0.0.0.0/0' && IngressSecurityRules.protocol = 6 && IngressSecurityRules.tcpOptions.destinationPortRange.max >= 22 && IngressSecurityRules.tcpOptions.destinationPortRange.min =<= 22) ```6. Ensure the query returns no results.**From CLI:**1. Execute the following command:```oci search resource structured-search --query-text query SecurityList resources where (IngressSecurityRules.source = '0.0.0.0/0' && IngressSecurityRules.protocol = 6 && IngressSecurityRules.tcpOptions.destinationPortRange.max >= 22 && IngressSecurityRules.tcpOptions.destinationPortRange.min <= 22) ```2. Ensure the query returns no results.**Cloud Guard**Ensure Cloud Guard is enabled in the root compartment of the tenancy. For more information about enabling Cloud Guard, please look at the instructions included in Recommendation 3.15.**From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console.2. Click `Cloud Guard` from the “Services” submenu.3. Click `Detector Recipes` in the Cloud Guard menu.4. Click `OCI Configuration Detector Recipe (Oracle Managed)` under the Recipe Name column.5. Find VCN Security list allows traffic to non-public port from all sources (0.0.0.0/0) in the Detector Rules column.6. Select the vertical ellipsis icon and chose Edit on the VCN Security list allows traffic to non-public port from all sources (0.0.0.0/0) row.7. In the Edit Detector Rule window find the Input Setting box and verify/add to the Restricted Protocol: Ports List setting to TCP:[22], UDP:[22].8. Click the `Save` button.**From CLI:**1. Update the VCN Security list allows traffic to non-public port from all sources (0.0.0.0/0) Detector Rule in Cloud Guard to generate Problems if a VCN security list allows public access via port 22 with the following command:```oci cloud-guard detector-recipe-detector-rule update --detector-recipe-id --detector-rule-id SECURITY_LISTS_OPEN_SOURCE --details '{configurations:[{ configKey : securityListsOpenSourceConfig, name : Restricted Protocol:Ports List, value : TCP:[22], UDP:[22], dataType : null, values : null }]}'```", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "2.2", + "Description": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 3389", + "Checks": [ + "network_security_list_ingress_from_internet_to_rdp_port" + ], + "Attributes": [ + { + "Section": "2. Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Security lists provide stateful and stateless filtering of ingress and egress network traffic to OCI resources on a subnet level. It is recommended that no security group allows unrestricted ingress access to port 3389.", + "RationaleStatement": "Removing unfettered connectivity to remote console services, such as Remote Desktop Protocol (RDP), reduces a server's exposure to risk.", + "ImpactStatement": "For updating an existing environment, care should be taken to ensure that administrators currently relying on an existing ingress from 0.0.0.0/0 have access to ports 22 and/or 3389 through another network security group or security list.", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above.2. For each security list in the returned results, click the security list name3. Either edit the `ingress rule` to be more restrictive, delete the `ingress rule` or click on the `VCN` and terminate the `security list` as appropriate.**From CLI:**1. Follow the audit procedure.2. For each of the `security lists` identified, execute the following command:```oci network security-list get --security-list-id ```3. Then either: - Update the `security list` by copying the `ingress-security-rules` element from the JSON returned by the above command, edit it appropriately, and use it in the following command```oci network security-list update --security-list-id --ingress-security-rules ''``` or - Delete the security list with the following command:```oci network security-list delete --security-list-id ```", + "AuditProcedure": "**From Console:**1. Login into the OCI Console2. Click in the search bar at the top of the screen.3. Type `Advanced Resource Query` and hit `enter`.4. Click the `Advanced Resource Query` button in the upper right corner of the screen.5. Enter the following query in the query box:```query SecurityList resources where (IngressSecurityRules.source = '0.0.0.0/0' && IngressSecurityRules.protocol = 6 && IngressSecurityRules.tcpOptions.destinationPortRange.max >= 3389 && IngressSecurityRules.tcpOptions.destinationPortRange.min <= 3389) ```6. Ensure query returns no results.**From CLI:**1. Execute the following command:```oci search resource structured-search --query-text query SecurityList resources where (IngressSecurityRules.source = '0.0.0.0/0' && IngressSecurityRules.protocol = 6 && IngressSecurityRules.tcpOptions.destinationPortRange.max >= 3389 && IngressSecurityRules.tcpOptions.destinationPortRange.min <= 3389) ```2. Ensure query returns no results.**Cloud Guard**To Enable Cloud Guard Auditing:Ensure Cloud Guard is enabled in the root compartment of the tenancy. For more information about enabling Cloud Guard, please look at the instructions included in Recommendation 3.15. **From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console .2. Click `Cloud Guard` from the “Services” submenu.3. Click `Detector Recipes` in the Cloud Guard menu.4. Click `OCI Configuration Detector Recipe (Oracle Managed)` under the Recipe Name column.5. Find VCN Security list allows traffic to non-public port from all sources (0.0.0.0/0) in the Detector Rules column.6. Select the vertical ellipsis icon and choose Edit on the VCN Security list allows traffic to non-public port from all sources (0.0.0.0/0) row.7. In the Edit Detector Rule window find the Input Setting box and verify/add to the Restricted Protocol: Ports List setting to TCP:[3389], UDP:[3389].8. Click the `Save` button.**From CLI:**1. Update the VCN Security list allows traffic to non-public port from all sources (0.0.0.0/0) Detector Rule in Cloud Guard to generate Problems if a VCN security list allows public access via port 3389 with the following command:```oci cloud-guard detector-recipe-detector-rule update --detector-recipe-id --detector-rule-id SECURITY_LISTS_OPEN_SOURCE --details '{configurations:[{ configKey : securityListsOpenSourceConfig, name : Restricted Protocol:Ports List, value : TCP:[3389], UDP:[3389], dataType : null, values : null }]}'```", + "AdditionalInformation": "This recommendation can also be audited programmatically using REST API https://docs.oracle.com/en-us/iaas/api/#/en/iaas/20160918/SecurityList/ListSecurityLists", + "References": "" + } + ] + }, + { + "Id": "2.3", + "Description": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 22", + "Checks": [ + "network_security_group_ingress_from_internet_to_ssh_port" + ], + "Attributes": [ + { + "Section": "2. Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Network security groups provide stateful filtering of ingress/egress network traffic to OCI resources. It is recommended that no security group allows unrestricted ingress to port 22.", + "RationaleStatement": "Removing unfettered connectivity to remote console services, such as Secure Shell (SSH), reduces a server's exposure to risk.", + "ImpactStatement": "For updating an existing environment, care should be taken to ensure that administrators currently relying on an existing ingress from 0.0.0.0/0 have access to ports 22 and/or 3389 through another network security group or security list.", + "RemediationProcedure": "**From Console:** 1. Login into the OCI Console. 2. Click the search bar at the top of the screen. 3. Type Advanced Resource Query and hit enter. 4. Click the Advanced Resource Query button in the upper right corner of the screen. 5. Enter the following query in the query box: query networksecuritygroup resources where lifeCycleState = 'AVAILABLE' 6. For each of the network security groups in the returned results, click the name and inspect each of the security rules. 7. Remove all security rules with direction: Ingress, Source: 0.0.0.0/0, and Destination Port Range: 22.**From CLI:**Issue the following command and identify the security rule to remove.``` for region in `oci iam region list | jq -r '.data[] | .name'`; do for compid in `oci iam compartment list 2>/dev/null | jq -r '.data[] | .id'`; do for nsgid in `oci network nsg list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | .id'` do output=`oci network nsg rules list --nsg-id=$nsgid --all 2>/dev/null | jq -r '.data[] | select(.source == 0.0.0.0/0 and .direction == INGRESS and ((.tcp-options.destination-port-range.max >= 22 and .tcp-options.destination-port-range.min <= 22) or .tcp-options.destination-port-range == null))'` if [ ! -z $output ]; then echo NSGID=, $nsgid, Security Rules=, $output; fi done done done```- Remove the security rules```oci network nsg rules remove --nsg-id=```or- Update the security rules```oci network nsg rules update --nsg-id= --security-rules='[]'eg: oci network nsg rules update --nsg-id=ocid1.networksecuritygroup.oc1.iad.xxxxxxxxxxxxxxxxxxxxxx --security-rules='[{ description: null, destination: null, destination-type: null, direction: INGRESS, icmp-options: null, id: 709001, is-stateless: null, protocol: 6, source: 140.238.154.0/24, source-type: CIDR_BLOCK, tcp-options: { destination-port-range: { max: 22, min: 22 }, source-port-range: null }, udp-options: null }]'```", + "AuditProcedure": "**From Console:** 1. Login into the OCI Console. 2. Click the search bar at the top of the screen. 3. Type Advanced Resource Query and hit enter. 4. Click the Advanced Resource Query button in the upper right corner of the screen. 5. Enter the following query in the query box:```query networksecuritygroup resources where lifeCycleState = 'AVAILABLE'``` 6. For each of the network security groups in the returned results, click the name and inspect each of the security rules. 7. Ensure that there are no security rules with direction: Ingress, Source: 0.0.0.0/0, and Destination Port Range: 22.**From CLI:**Issue the following command, it should return no values.```for region in $(oci iam region-subscription list | jq -r '.data[] | .region-name') do echo Enumerating region $region for compid in $(oci iam compartment list --include-root --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id') do echo Enumerating compartment $compid for nsgid in $(oci network nsg list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | .id') do output=$(oci network nsg rules list --nsg-id=$nsgid --all 2>/dev/null | jq -r '.data[] | select(.source == 0.0.0.0/0 and .direction == INGRESS and ((.tcp-options.destination-port-range.max >= 22 and .tcp-options.destination-port-range.min <= 22) or .tcp-options.destination-port-range == null))') if [ ! -z $output ]; then echo NSGID: , $nsgid, Security Rules: , $output; fi done done done```**Cloud Guard:**To Enable Cloud Guard Auditing:Ensure Cloud Guard is enabled in the root compartment of the tenancy. For more information about enabling Cloud Guard, please look at the instructions included in Recommendation 3.15. **From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console .2. Click `Cloud Guard` from the “Services” submenu.3. Click `Detector Recipes` in the Cloud Guard menu.4. Click `OCI Configuration Detector Recipe (Oracle Managed)` under the Recipe Name column.5. Find NSG ingress rule contains disallowed IP/port in the Detector Rules column.6. Select the vertical ellipsis icon and chose Edit on the NSG ingress rule contains disallowed IP/port row.7. In the Edit Detector Rule window find the Input Setting box and verify/add to the Restricted Protocol: Ports List setting to TCP:[22], UDP:[22].8. Click the `Save` button.**From CLI:**1. Update the NSG ingress rule contains disallowed IP/port Detector Rule in Cloud Guard to generate Problems if a network security group allows ingress network traffic to port 22 with the following command:```oci cloud-guard detector-recipe-detector-rule update --detector-recipe-id --detector-rule-id VCN_NSG_INGRESS_RULE_PORTS_CHECK --details '{configurations:[ {configKey : nsgIngressRuleDisallowedPortsConfig, name : Default disallowed ports, value : TCP:[22], UDP:[22], dataType : null, values : null }]}'```", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "2.4", + "Description": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 3389", + "Checks": [ + "network_security_group_ingress_from_internet_to_rdp_port" + ], + "Attributes": [ + { + "Section": "2. Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Network security groups provide stateful filtering of ingress/egress network traffic to OCI resources. It is recommended that no security group allows unrestricted ingress access to port 3389.", + "RationaleStatement": "Removing unfettered connectivity to remote console services, such as Remote Desktop Protocol (RDP), reduces a server's exposure to risk.", + "ImpactStatement": "For updating an existing environment, care should be taken to ensure that administrators currently relying on an existing ingress from 0.0.0.0/0 have access to ports 22 and/or 3389 through another network security group or security list.", + "RemediationProcedure": "**From CLI:**Using the details returned from the audit procedure either:- Remove the security rules```oci network nsg rules remove --nsg-id=```or- Update the security rules```oci network nsg rules update --nsg-id= --security-rules=eg: oci network nsg rules update --nsg-id=ocid1.networksecuritygroup.oc1.iad.xxxxxxxxxxxxxxxxxxxxxx --security-rules='[{ description: null, destination: null, destination-type: null, direction: INGRESS, icmp-options: null, id: 709001, is-stateless: null, protocol: 6, source: 140.238.154.0/24, source-type: CIDR_BLOCK, tcp-options: { destination-port-range: { max: 3389, min: 3389 }, source-port-range: null }, udp-options: null }]'```", + "AuditProcedure": "**From CLI:**Issue the following command, it should not return anything.``` for region in $(oci iam region-subscription list | jq -r '.data[] | .region-name') do echo Enumerating region $region for compid in $(oci iam compartment list --include-root --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id') do echo Enumerating compartment $compid for nsgid in $(oci network nsg list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | .id') do output=$(oci network nsg rules list --nsg-id=$nsgid --all 2>/dev/null | jq -r '.data[] | select(.source == 0.0.0.0/0 and .direction == INGRESS and ((.tcp-options.destination-port-range.max >= 3389 and .tcp-options.destination-port-range.min <= 3389) or .tcp-options.destination-port-range == null))') if [ ! -z $output ]; then echo NSGID: , $nsgid, Security Rules: , $output; fi done done done```**From Cloud Guard:**To Enable Cloud Guard Auditing:Ensure Cloud Guard is enabled in the root compartment of the tenancy. For more information about enabling Cloud Guard, please look at the instructions included in Recommendation 3.15. **From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console.2. Click `Cloud Guard` from the “Services” submenu.3. Click `Detector Recipes` in the Cloud Guard menu.4. Click `OCI Configuration Detector Recipe (Oracle Managed)` under the Recipe Name column.5. Find NSG ingress rule contains disallowed IP/port in the Detector Rules column.6. Select the vertical ellipsis icon and chose Edit on the NSG ingress rule contains disallowed IP/port row.7. In the Edit Detector Rule window find the Input Setting box and verify/add to the Restricted Protocol: Ports List setting to TCP:[3389], UDP:[3389].8. Click the Save button.**From CLI:**1. Update the NSG ingress rule contains disallowed IP/port Detector Rule in Cloud Guard to generate Problems if a network security group allows ingress network traffic to port 3389 with the following command:```oci cloud-guard detector-recipe-detector-rule update --detector-recipe-id --detector-rule-id VCN_NSG_INGRESS_RULE_PORTS_CHECK --details '{configurations:[ {configKey : nsgIngressRuleDisallowedPortsConfig, name : Default disallowed ports, value : TCP:[3389], UDP:[3389], dataType : null, values : null }]}'```", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "2.5", + "Description": "Ensure the default security list of every VCN restricts all traffic except ICMP", + "Checks": [ + "network_default_security_list_restricts_traffic" + ], + "Attributes": [ + { + "Section": "2. Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "A default security list is created when a Virtual Cloud Network (VCN) is created and attached to the public subnets in the VCN. Security lists provide stateful or stateless filtering of ingress and egress network traffic to OCI resources in the VCN. It is recommended that the default security list does not allow unrestricted ingress and egress access to resources in the VCN.", + "RationaleStatement": "Removing unfettered connectivity to OCI resource, reduces a server's exposure to unauthorized access or data exfiltration.", + "ImpactStatement": "For updating existing environments Ingress rules with a source of 0.0.0.0/0, ensure that the necessary access is available through another Network Security Group or Security List.For updating existing environments Egress rules with a destination of 0.0.0.0/0 for an existing environment, ensure egress is covered via another Network Security Group, Security List, or through the stateful nature of the ingress rule.", + "RemediationProcedure": "**From Console:**1. Login into the OCI Console2. Click on `Networking -> Virtual Cloud Networks` from the services menu3. For each VCN listed `Click on Security Lists`4. Click on `Default Security List for `5. Identify the Ingress Rule with 'Source 0.0.0.0/0'6. Either Edit the Security rule to restrict the source and/or port range or delete the rule.7. Identify the Egress Rule with 'Destination 0.0.0.0/0, All Protocols'8. Either Edit the Security rule to restrict the source and/or port range or delete the rule.", + "AuditProcedure": "**From Console:**1. Login into the OCI Console2. Click on `Networking -> Virtual Cloud Networks` from the services menu3. For each VCN listed `Click on Security Lists`4. Click on `Default Security List for `5. Verify that there is no Ingress rule with 'Source 0.0.0.0/0'6. Verify that there is no Egress rule with 'Destination 0.0.0.0/0, All Protocols'", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en-us/iaas/Content/Security/Reference/networking_security.htm#Securing_Networking_VCN_Load_Balancers_and_DNS" + } + ] + }, + { + "Id": "2.6", + "Description": "Ensure Oracle Integration Cloud (OIC) access is restricted to allowed sources", + "Checks": [ + "integration_instance_access_restricted" + ], + "Attributes": [ + { + "Section": "2. Networking", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Oracle Integration (OIC) is a complete, secure, but lightweight integration solution that enables you to connect your applications in the cloud. It simplifies connectivity between your applications and connects both your applications that live in the cloud and your applications that still live on premises. Oracle Integration provides secure, enterprise-grade connectivity regardless of the applications you are connecting or where they reside. OIC instances are created within an Oracle managed secure private network with each having a public endpoint. The capability to configure ingress filtering of network traffic to protect your OIC instances from unauthorized network access is included. It is recommended that network access to your OIC instances be restricted to your approved corporate IP Addresses or Virtual Cloud Networks (VCN)s.", + "RationaleStatement": "Restricting connectivity to OIC Instances reduces an OIC instance’s exposure to risk.", + "ImpactStatement": "When updating ingress filters for an existing environment, care should be taken to ensure that IP addresses and VCNs currently used by administrators, users, and services to access your OIC instances are included in the updated filters.", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above.2. For each OIC instance in the returned results, click the OIC Instance name3. Click `Network Access`4. Either edit the `Network Access` to be more restrictive **From CLI**1. Follow the audit procedure.2. Get the json input format using the below command:```oci integration integration-instance change-network-endpoint --generate-param-json-input```3.For each of the OIC Instances identified get its details.4.Update the `Network Access`, copy the `network-endpoint-details` element from the JSON returned by the above get call, edit it appropriately and use it in the following command```Oci integration integration-instance change-network-endpoint --id --from-json ''```", + "AuditProcedure": "**From Console:**1. Login into the OCI Console2. Click in the search bar, top of the screen.3. Type Advanced Resource Query and hit enter.4. Click the Advanced Resource Query button in the upper right of the screen.5. Enter the following query in the query box:```query integrationinstance resources```6. For each OIC Instance returned click on the link under `Display name`7. Click on `Network Access`8 .Ensure `Restrict Network Access` is selected and the IP Address/CIDR Block as well as Virtual Cloud Networks are correct9. Repeat for other subscribed regions**From CLI:**1. Execute the following command:```for region in `oci iam region list | jq -r '.data[] | .name'`; do for compid in `oci iam compartment list --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id'` do output=`oci integration integration-instance list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | select(.network-endpoint-details.network-endpoint-type == null)'` if [ ! -z $output ]; then echo $output; fi done done```2. Ensure `allowlisted-http-ips` and `allowed-http-vcns` are correct", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en/cloud/paas/integration-cloud/integrations-user/get-started-integration-cloud-service.html" + } + ] + }, + { + "Id": "2.7", + "Description": "Ensure Oracle Analytics Cloud (OAC) access is restricted to allowed sources or deployed within a Virtual Cloud Network", + "Checks": [ + "analytics_instance_access_restricted" + ], + "Attributes": [ + { + "Section": "2. Networking", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Oracle Analytics Cloud (OAC) is a scalable and secure public cloud service that provides a full set of capabilities to explore and perform collaborative analytics for you, your workgroup, and your enterprise. OAC instances provide ingress filtering of network traffic or can be deployed with in an existing Virtual Cloud Network VCN. It is recommended that all new OAC instances be deployed within a VCN and that the Access Control Rules are restricted to your corporate IP Addresses or VCNs for existing OAC instances.", + "RationaleStatement": "Restricting connectivity to Oracle Analytics Cloud instances reduces an OAC instance’s exposure to risk.", + "ImpactStatement": "When updating ingress filters for an existing environment, care should be taken to ensure that IP addresses and VCNs currently used by administrators, users, and services to access your OAC instances are included in the updated filters. Also, these changes will temporarily bring the OAC instance offline.", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above.2. For each OAC instance in the returned results, click the OAC Instance name3. Click `Edit` next to `Access Control Rules`4. Click `+Another Rule` and add rules as required**From CLI:**1. Follow the audit procedure.2. Get the json input format by executing the below command:```oci analytics analytics-instance change-network-endpoint --generate-full-command-json-input```3. For each of the OAC Instances identified get its details.4. Update the `Access Control Rules`, copy the `network-endpoint-details` element from the JSON returned by the above get call, edit it appropriately and use it in the following command:```oci integration analytics-instance change-network-endpoint --from-json ''```", + "AuditProcedure": "**From Console:**1 Login into the OCI Console2. Click in the search bar, top of the screen.3. Type Advanced Resource Query and hit enter.4. Click the Advanced Resource Query button in the upper right of the screen.5. Enter the following query in the query box:```query analyticsinstance resources```6. For each OAC Instance returned click on the link under `Display name`.7. Ensure `Access Control Rules` IP Address/CIDR Block as well as Virtual Cloud Networks are correct.8. Repeat for other subscribed regions.**From CLI:**1. Execute the following command:```for region in `oci iam region list | jq -r '.data[] | .name'`; do for compid in `oci iam compartment list --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id'` do output=`oci analytics analytics-instance list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | select(.network-endpoint-details.network-endpoint-type == PUBLIC)'` if [ ! -z $output ]; then echo $output; fi done done```2. Ensure `network-endpoint-type` are correct.", + "AdditionalInformation": "https://docs.oracle.com/en/cloud/paas/analytics-cloud/acoci/manage-service-access-and-security.html#GUID-3DB25824-4417-4981-9EEC-29C0C6FD3883", + "References": "" + } + ] + }, + { + "Id": "2.8", + "Description": "Ensure Oracle Autonomous Shared Databases (ADB) access is restricted to allowed sources or deployed within a Virtual Cloud Network", + "Checks": [ + "database_autonomous_database_access_restricted" + ], + "Attributes": [ + { + "Section": "2. Networking", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Oracle Autonomous Database Shared (ADB-S) automates database tuning, security, backups, updates, and other routine management tasks traditionally performed by DBAs. ADB-S provide ingress filtering of network traffic or can be deployed within an existing Virtual Cloud Network (VCN). It is recommended that all new ADB-S databases be deployed within a VCN and that the Access Control Rules are restricted to your corporate IP Addresses or VCNs for existing ADB-S databases.", + "RationaleStatement": "Restricting connectivity to ADB-S Databases reduces an ADB-S database’s exposure to risk.", + "ImpactStatement": "When updating ingress filters for an existing environment, care should be taken to ensure that IP addresses and VCNs currently used by administrators, users, and services to access your ADB-S instances are included in the updated filters.", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above.2. For each ADB-S database in the returned results, click the ADB-S database name3. Click `Edit` next to `Access Control Rules`4. Click `+Another Rule` and add rules as required5. Click `Save Changes`**From CLI:**1. Follow the audit procedure.2. Get the json input format by executing the following command:```oci db autonomous-database update --generate-full-command-json-input```3. For each of the ADB-S Database identified get its details.4. Update the `whitelistIps`, copy the `WhiteListIPs` element from the JSON returned by the above get call, edit it appropriately and use it in the following command:```oci db autonomous-database update –-autonomous-database-id --from-json ''```", + "AuditProcedure": "**From Console:**1. Login into the OCI Console2. Click in the search bar, top of the screen.3. Type Advanced Resource Query and hit enter.4. Click the `Advanced Resource Query` button in the upper right of the screen.5. Enter the following query in the query box:```query autonomousdatabase resources```6. For each ABD-S database returned click on the link under `Display name`7. Click `Edit` next to `Access Control List`8. Ensure `Access Control Rules’ IP Address/CIDR Block as well as VCNs are correct9. Repeat for other subscribed regions**From CLI:**1. Execute the following command:```for region in `oci iam region list | jq -r '.data[] | .name'`; do for compid in `oci iam compartment list --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id'` do for adbid in `oci db autonomous-database list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | select(.nsg-ids == null).id'` do output=`oci db autonomous-database get --autonomous-database-id $adbid --region $region --query=data.{WhiteListIPs:\\whitelisted-ips\\,id:id} --output table 2>/dev/null` if [ ! -z $output ]; then echo $output; fi done done done```2. Ensure `WhiteListIPs` are correct.", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en/cloud/paas/autonomous-database/adbsa/network-access-options.html#GUID-29D62917-0F18-4F3E-8081-B3BD5C0C79F5" + } + ] + }, + { + "Id": "3.1", + "Description": "Ensure Compute Instance Legacy Metadata service endpoint is disabled", + "Checks": [ + "compute_instance_legacy_metadata_endpoint_disabled" + ], + "Attributes": [ + { + "Section": "3. Compute", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Compute Instances that utilize Legacy MetaData service endpoints (IMDSv1) are susceptible to potential SSRF attacks. To bolster security measures, it is strongly advised to reconfigure Compute Instances to adopt Instance Metadata Service v2, aligning with the industry's best security practices.", + "RationaleStatement": "Enabling Instance Metadata Service v2 enhances security and grants precise control over metadata access. Transitioning from IMDSv1 reduces the risk of SSRF attacks, bolstering system protection.IMDv1 poses security risks due to its inferior security measures and limited auditing capabilities. Transitioning to IMDv2 ensures a more secure environment with robust security features and improved monitoring capabilities.", + "ImpactStatement": "If you disable IMDSv1 on an instance that does not support IMDSv2, you might not be able to connect to the instance when you launch it.IMDSv2 is supported on the following platform images:- Oracle Autonomous Linux 8.x images- Oracle Autonomous Linux 7.x images released in June 2020 or later- Oracle Linux 8.x, Oracle Linux 7.x, and Oracle Linux 6.x images released in July 2020 or laterOther platform images, most custom images, and most Marketplace images do not support IMDSv2. Custom Linux images might support IMDSv2 if cloud-init is updated to version 20.3 or later and Oracle Cloud Agent is updated to version 0.0.19 or later. Custom Windows images might support IMDSv2 if Oracle Cloud Agent is updated to version 1.0.0.0 or later; cloudbase-init does not support IMDSv2.", + "RemediationProcedure": "**From Console:**1. Login to the OCI Console2. Click on the search box at the top of the console and search for compute instance name.3. Click on the instance name, In the `Instance Details` section, next to Instance Metadata Service, click `Edit`.4. For the `Instance metadata service`, select the `Version 2 only` option.5. Click `Save Changes`.Note : Disabling IMDSv1 on an incompatible instance may result in connectivity issues upon launch.To re-enable IMDSv1, follow these steps: 1. On the Instance Details page in the Console, click `Edit` next to Instance Metadata Service.2. Choose the `Version 1 and version 2` option, and save your changes.**From CLI:**Run Below Command,```oci compute instance update --instance-id [instance-ocid] --instance-options '{areLegacyImdsEndpointsDisabled :true}'```This will set Instance Metadata Service to use Version 2 Only.", + "AuditProcedure": "**From Console:**1. Login to the OCI Console2. Select compute instance in your compartment.3. Click on each instance name.4. In the `Instance Details` section, next to `Instance metadata service` make sure `Version 2 only` is selected.**From CLI:**1. Run command:```for region in `oci iam region-subscription list | jq -r '.data[] | .region-name'`; do for compid in `oci iam compartment list --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id'` do output=`oci compute instance list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | select(.instance-options.are-legacy-imds-endpoints-disabled == false )'` if [ ! -z $output ]; then echo $output; fi done done```2. No results should be returned", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/gettingmetadata.htm" + } + ] + }, + { + "Id": "3.2", + "Description": "Ensure Secure Boot is enabled on Compute Instance", + "Checks": [ + "compute_instance_secure_boot_enabled" + ], + "Attributes": [ + { + "Section": "3. Compute", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Shielded Instances with Secure Boot enabled prevents unauthorized boot loaders and operating systems from booting. This prevent rootkits, bootkits, and unauthorized software from running before the operating system loads.Secure Boot verifies the digital signature of the system's boot software to check its authenticity. The digital signature ensures the operating system has not been tampered with and is from a trusted source.When the system boots and attempts to execute the software, it will first check the digital signature to ensure validity. If the digital signature is not valid, the system will not allow the software to run.Secure Boot is a feature of UEFI(Unified Extensible Firmware Interface) that only allows approved operating systems to boot up.", + "RationaleStatement": "A Threat Actor with access to the operating system may seek to alter boot components to persist malware or rootkits during system initialization. Secure Boot helps ensure that the system only runs authentic software by verifying the digital signature of all boot components.", + "ImpactStatement": "An existing instance cannot be changed to a Shielded instance with Secure boot enabled. Shielded Secure Boot not available on all instance shapes and Operating systems. Additionally the following limitations exist:Thus to enable you have to terminate the instance and create a new one. Also, Shielded instances do not support live migration. During an infrastructure maintenance event, Oracle Cloud Infrastructure live migrates supported VM instances from the physical VM host that needs maintenance to a healthy VM host with minimal disruption to running instances. If you enable Secure Boot on an instance, the instance cannot be migrated, because the hardware TPM is not migratable. This may result in an outage because the TPM can't be migrate from a unhealthy host to healthy host.", + "RemediationProcedure": "Note: Secure Boot facility is available on selected VM images and Shapes in OCI. User have to configure Secured Boot at time of instance creation only.**From Console:**1. Navigate to https://cloud.oracle.com/compute/instances1. Select the instance from the Audit Procedure1. Click `Terminate`.1. Determine whether or not to permanently delete instance's attached boot volume.1. Click `Terminate instance`.1. Click on `Create Instance`.1. Select Image and Shape which supports Shielded Instance configuration. Icon for Shield in front of Image/Shape row indicates support of Shielded Instance.1. Click on `edit` of Security Blade.1. Turn On Shielded Instance, then Turn on the Secure Boot Toggle.1. Fill in the rest of the details as per requirements.1. Click `Create`.", + "AuditProcedure": "**From Console:**1. Login to the OCI Console2. Select compute instance in your compartment.3. Click on each instance name.4. In the `Launch Options` section,5. Check if `Secure Boot` is `Enabled`.**From CLI:**Run command:```for region in `oci iam region-subscription list | jq -r '.data[] | .region-name'`; do for compid in `oci iam compartment list --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id'` do output=`oci compute instance list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | select(.platform-config == null or platform-config.is-secure-boot-enabled == false )'` if [ ! -z $output ]; then echo $output; fi done done```In response, check if `platform-config` are not null and `is-secure-boot-enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en-us/iaas/Content/Compute/References/shielded-instances.htm:https://uefi.org/sites/default/files/resources/UEFI_Secure_Boot_in_Modern_Computer_Security_Solutions_2013.pdf" + } + ] + }, + { + "Id": "3.3", + "Description": "Ensure In-transit Encryption is enabled on Compute Instance", + "Checks": [ + "compute_instance_in_transit_encryption_enabled" + ], + "Attributes": [ + { + "Section": "3. Compute", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The Block Volume service provides the option to enable in-transit encryption for paravirtualized volume attachments on virtual machine (VM) instances.", + "RationaleStatement": "All the data moving between the instance and the block volume is transferred over an internal and highly secure network. If you have specific compliance requirements related to the encryption of the data while it is moving between the instance and the block volume, you should enable the in-transit encryption option.", + "ImpactStatement": "In-transit encryption for boot and block volumes is only available for virtual machine (VM) instances launched from platform images, along with bare metal instances that use the following shapes: BM.Standard.E3.128, BM.Standard.E4.128, BM.DenseIO.E4.128. It is not supported on other bare metal instances.", + "RemediationProcedure": "**From Console:**1. Navigate to https://cloud.oracle.com/compute/instances1. Select the instance from the Audit Procedure1. Click `Terminate`.1. Determine whether or not to permanently delete instance's attached boot volume.1. Click `Terminate instance`.1. Click on `Create Instance`.1. Fill in the details as per requirements.1. In the `Boot volume` section ensure `Use in-transit encryption` is checked.1. Fill in the rest of the details as per requirements.1. Click `Create`.", + "AuditProcedure": "**From Console:**1. Go to [https://cloud.oracle.com/compute/instances](https://cloud.oracle.com/compute/instances)2. Select compute instance in your compartment.3. Click on each instance name.4. Click on `Boot volume` on the bottom left.5. Under the `In-transit encryption` column make sure it is `Enabled`**From CLI:**1. Execute the following:```for region in `oci iam region-subscription list | jq -r '.data[] | .region-name'`; do for compid in `oci iam compartment list --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id'` do output=`oci compute instance list --compartment-id $compid --region $region --all 2>/dev/null | jq -r '.data[] | select(.launch-options.is-pv-encryption-in-transit-enabled == false )'` if [ ! -z $output ]; then echo $output; fi done done```2. Ensure no results are returned", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en-us/iaas/Content/Block/Concepts/overview.htm#BlockVolumeEncryption__intransit" + } + ] + }, + { + "Id": "4.1", + "Description": "Ensure default tags are used on resources", + "Checks": [], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Using default tags is a way to ensure all resources that support tags are tagged during creation. Tags can be based on static or computed values. It is recommended to set up default tags early after root compartment creation to ensure all created resources will get tagged.Tags are scoped to Compartments and are inherited by Child Compartments. The recommendation is to create default tags like “CreatedBy” at the Root Compartment level to ensure all resources get tagged.When using Tags it is important to ensure that Tag Namespaces are protected by IAM Policies otherwise this will allow users to change tags or tag values.Depending on the age of the OCI Tenancy there may already be Tag defaults setup at the Root Level and no need for further action to implement this action.", + "RationaleStatement": "In the case of an incident having default tags like “CreatedBy” applied will provide info on who created the resource without having to search the Audit logs.", + "ImpactStatement": "There is no performance impact when enabling the above described features.", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.2. From the navigation menu, select `Governance & Administration`.3. Under `Tenancy Management`, select `Tag Namespaces`.4. Under `Compartment`, select the root compartment.5. If no tag namespace exists, click `Create Tag Namespace`, enter a name and description and click `Create Tag Namespace`.6. Click the name of a tag namespace.7. Click `Create Tag Key Definition`.8. Enter a tag key (e.g. CreatedBy) and description, and click `Create Tag Key Definition`.9. From the navigation menu, select `Identity & Security`.10. Under `Identity`, select `Compartments`.11. Click the name of the root compartment.12. Under `Resources`, select `Tag Defaults`.13. Click `Create Tag Default`.14. Select a tag namespace, tag key, and enter `${iam.principal.name}` as the tag value.15. Click `Create`.**From CLI:**1. Create a Tag Namespace in the Root Compartment```oci iam tag-namespace create --compartment-id= --name= --description= --query data.{\\Tag Namespace OCID\\:id} --output table```2. Note the Tag Namespace OCID and use it when creating the Tag Key Definition```oci iam tag create --tag-namespace-id= --name= --description= --query data.{\\Tag Key Definition OCID\\:id} --output table```3. Note the Tag Key Definition OCID and use it when creating the Tag Default in the Root compartment```oci iam tag-default create --compartment-id= --tag-definition-id= --value=\\${iam.principal.name}```", + "AuditProcedure": "**From Console:**1. Login to OCI Console.2. From the navigation menu, select `Identity & Security`.3. Under `Identity`, select `Compartments`.4. Click the name of the root compartment.5. Under `Resources`, select `Tag Defaults`.6. In the `Tag Defaults` table, verify that there is a Tag with a value of `${iam.principal.name}` and a Tag Key Status of `Active`.Note: The name of the tag may be different then “CreatedBy” if the Tenancy Administrator has decided to use another tag.**From CLI:**1. List the active tag defaults defined at the Root compartment level by using the Tenancy OCID as compartment id.Note: The Tenancy OCID can be found in the `~/.oci/config` file used by the OCI Command Line Tool```oci iam tag-default list --compartment-id= --query=data [?\\lifecycle-state\\=='ACTIVE'].{name:\\tag-definition-name\\,value:value} --output table```2. Verify in the table returned that there is at least one row that contains the value of `${iam.principal.name}`.", + "AdditionalInformation": "'- There is no requirement to use the “Oracle-Tags” namespace to implement this control. A Tag Namespace Administrator can create any namespace and use it for this control.", + "References": "" + } + ] + }, + { + "Id": "4.2", + "Description": "Create at least one notification topic and subscription to receive monitoring alerts", + "Checks": [ + "events_notification_topic_and_subscription_exists" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Notifications provide a multi-channel messaging service that allow users and applications to be notified of events of interest occurring within OCI. Messages can be sent via eMail, HTTPs, PagerDuty, Slack or the OCI Function service. Some channels, such as eMail require confirmation of the subscription before it becomes active.", + "RationaleStatement": "Creating one or more notification topics allow administrators to be notified of relevant changes made to OCI infrastructure.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the Notifications Service page: [https://console.us-ashburn-1.oraclecloud.com/notification/topics](https://console.us-ashburn-1.oraclecloud.com/notification/topics)2. Select the `Compartment` that hosts the notifications3. Click `Create Topic`4. Set the `name` to something relevant5. Set the `description` to describe the purpose of the topic6. Click `Create`7. Click the newly created topic8. Click `Create Subscription`9. Choose the correct `protocol`10. Complete the correct parameter, for instance `email` address11. Click `Create`**From CLI:**1. Create a topic in a compartment```oci ons topic create --name --description --compartment-id ```2. Note the `OCID` of the `topic` using the `topic-id` field of the returned JSON and use it to create a new subscription```oci ons subscription create --compartment-id --topic-id --protocol --subscription-endpoint ```3. The returned JSON includes the id of the `subscription`.", + "AuditProcedure": "**From Console:**1. Go to the Notifications Service page: [https://console.us-ashburn-1.oraclecloud.com/notification/topics](https://console.us-ashburn-1.oraclecloud.com/notification/topics)2. Select the `Compartment` that hosts the notifications3. Find and click the `Topic` relevant to your monitoring alerts.4. Ensure a valid active subscription is shown.**From CLI:** 1. List the topics in the `Compartment` that hosts the notifications```oci ons topic list --compartment-id --all```2. Note the `OCID` of the monitoring topic(s) using the `topic-id` field of the returned JSON and use it to list the subscriptions```oci ons subscription list --compartment-id --topic-id --all```3. Ensure at least one active subscription is returned", + "AdditionalInformation": "'- The console URL shown is for the Ashburn region. Your tenancy might have a different home region and thus console URL.- The same Notification topic can be reused by many Events. A single topic can have multiple subscriptions allowing the same topic to be published to multiple locations.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.3", + "Description": "Ensure a notification is configured for Identity Provider changes", + "Checks": [ + "events_rule_identity_provider_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when Identity Providers are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments. It is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "OCI Identity Providers allow management of User ID / passwords in external systems and use of those credentials to access OCI resources. Identity Providers allow users to single sign-on to OCI console and have other OCI credentials like API Keys.Monitoring and alerting on changes to Identity Providers will help in identifying changes to the security posture.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Identity` in the Service Name Drop-down and selecting `Identity Provider – Create`, `Identity Provider - Delete and Identity Provider – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.identitycontrolplane.createidentityprovider\\,\\ com.oraclecloud.identitycontrolplane.deleteidentityprovider\\,\\ com.oraclecloud.identitycontrolplane.updateidentityprovider\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `Identity Provider` Changes (if any)4. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Identity` and Event Types: `Identity Provider – Create`, `Identity Provider - Delete` and `Identity Provider – Update`5. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:** 1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.identitycontrolplane.createidentityprovidercom.oraclecloud.identitycontrolplane.deleteidentityprovidercom.oraclecloud.identitycontrolplane.updateidentityprovider```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.4", + "Description": "Ensure a notification is configured for IdP group mapping changes", + "Checks": [ + "events_rule_idp_group_mapping_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when Identity Provider Group Mappings are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments. It is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "IAM Policies govern access to all resources within an OCI Tenancy. IAM Policies use OCI Groups for assigning the privileges. Identity Provider Groups could be mapped to OCI Groups to assign privileges to federated users in OCI. Monitoring and alerting on changes to Identity Provider Group mappings will help in identifying changes to the security posture.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Identity` in the Service Name Drop-down and selecting `Idp Group Mapping – Create`, `Idp Group Mapping – Delete` and `Idp Group Mapping – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.identitycontrolplane.addidpgroupmapping\\,\\com.oraclecloud.identitycontrolplane.removeidpgroupmapping\\,\\com.oraclecloud.identitycontrolplane.updateidpgroupmapping\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `Idp Group Mapping` Changes (if any)4. Ensure the `Rule` is `ACTIVE`5. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Identity` and Event Types: `Idp Group Mapping – Create`, `Idp Group Mapping – Delete` and `Idp Group Mapping – Update`6. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:** 1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.identitycontrolplane.addidpgroupmappingcom.oraclecloud.identitycontrolplane.removeidpgroupmappingcom.oraclecloud.identitycontrolplane.updateidpgroupmapping```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.5", + "Description": "Ensure a notification is configured for IAM group changes", + "Checks": [ + "events_rule_iam_group_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when IAM Groups are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments, it is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "IAM Groups control access to all resources within an OCI Tenancy. Monitoring and alerting on changes to IAM Groups will help in identifying changes to satisfy least privilege principle.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Identity` in the Service Name Drop-down and selecting `Group – Create`, `Group – Delete` and `Group – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending Notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition: {\\eventType\\:[\\com.oraclecloud.identitycontrolplane.creategroup\\,\\com.oraclecloud.identitycontrolplane.deletegroup\\,\\com.oraclecloud.identitycontrolplane.updategroup\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles IAM `Group` Changes4. Click the `Edit Rule` button and verify that the `Rule Conditions` section contains a condition for the Service `Identity` and Event Types: `Group – Create`, `Group – Delete` and `Group – Update`5. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:**1. Find the OCID of the specific Event Rule based on `Display Name` and `Compartment OCID````oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.identitycontrolplane.creategroupcom.oraclecloud.identitycontrolplane.deletegroupcom.oraclecloud.identitycontrolplane.updategroup```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is ONS and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.6", + "Description": "Ensure a notification is configured for IAM policy changes", + "Checks": [ + "events_rule_iam_policy_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when IAM Policies are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments, it is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "IAM Policies govern access to all resources within an OCI Tenancy. Monitoring and alerting on changes to IAM policies will help in identifying changes to the security posture.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Identity` in the Service Name Drop-down and selecting `Policy – Change Compartment`, `Policy – Create`, `Policy - Delete` and `Policy – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending Notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.identitycontrolplane.createpolicy\\,\\com.oraclecloud.identitycontrolplane.deletepolicy\\,\\com.oraclecloud.identitycontrolplane.updatepolicy\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `IAM Policy` Changes (if any)4. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Identity` and Event Types: `Policy – Create`, ` Policy - Delete` and `Policy – Update`5. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:** 1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.identitycontrolplane.createpolicycom.oraclecloud.identitycontrolplane.deletepolicycom.oraclecloud.identitycontrolplane.updatepolicy```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.7", + "Description": "Ensure a notification is configured for user changes", + "Checks": [ + "events_rule_user_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when IAM Users are created, updated, deleted, capabilities updated, or state updated. Event Rules are compartment scoped and will detect events in child compartments, it is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "Users use or manage Oracle Cloud Infrastructure resources. Monitoring and alerting on changes to Users will help in identifying changes to the security posture.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Using the search box to navigate to `events`2. Navigate to the `rules` page3. Select the `compartment` that should host the rule4. Click `Create Rule`5. Provide a `Display Name` and `Description`6. Create a Rule Condition by selecting `Identity` in the Service Name Drop-down and selecting:`User – Create`, `User – Delete`, `User – Update`, `User Capabilities – Update`,`User State – Update` 7. In the `Actions` section select `Notifications` as Action Type8. Select the `Compartment` that hosts the Topic to be used.9. Select the `Topic` to be used10. Optionally add Tags to the Rule11. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending Notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition: {\\eventType\\:[\\com.oraclecloud.identitycontrolplane.createuser\\,\\com.oraclecloud.identitycontrolplane.deleteuser\\,\\com.oraclecloud.identitycontrolplane.updateuser\\,\\com.oraclecloud.identitycontrolplane.updateusercapabilities\\,\\com.oraclecloud.identitycontrolplane.updateuserstate\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Using the search box to navigate to `events`2. Navigate to the `rules` page3. Select the `Compartment` that hosts the rules4. Find and click the `Rule` that handles `IAM User` Changes5. Click the `Edit Rule` button and verify that the `Rule Conditions` section contains a condition for the Service `Identity` and Event Types: `User – Create`, `User – Delete`, `User – Update`, `User Capabilities – Update`,`User State – Update` 6. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:**1. Find the OCID of the specific Event Rule based on `Display Name` and `Compartment OCID````oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.identitycontrolplane.createusercom.oraclecloud.identitycontrolplane.deleteusercom.oraclecloud.identitycontrolplane.updateusercom.oraclecloud.identitycontrolplane.updateusercapabilitiescom.oraclecloud.identitycontrolplane.updateuserstate```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is ONS and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.8", + "Description": "Ensure a notification is configured for VCN changes", + "Checks": [ + "events_rule_vcn_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when Virtual Cloud Networks are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments, it is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "Virtual Cloud Networks (VCNs) closely resembles a traditional network. Monitoring and alerting on changes to VCNs will help in identifying changes to the security posture.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Networking` in the Service Name Drop-down and selecting `VCN – Create`, ` VCN - Delete and VCN – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending Notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.virtualnetwork.createvcn\\,\\com.oraclecloud.virtualnetwork.deletevcn\\,\\com.oraclecloud.virtualnetwork.updatevcn\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `VCN` Changes (if any)4. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Networking` and Event Types: `VCN – Create`, ` VCN - Delete and VCN – Update`5. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:**1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.virtualnetwork.createvcncom.oraclecloud.virtualnetwork.deletevcncom.oraclecloud.virtualnetwork.updatevcn```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.9", + "Description": "Ensure a notification is configured for changes to route tables", + "Checks": [ + "events_rule_route_table_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when route tables are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments, it is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "Route tables control traffic flowing to or from Virtual Cloud Networks and Subnets. Monitoring and alerting on changes to route tables will help in identifying changes these traffic flows.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Networking` in the Service Name Drop-down and selecting `Route Table – Change Compartment`, `Route Table – Create`, `Route Table - Delete` and `Route Table – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending Notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.virtualnetwork.changeroutetablecompartment\\,\\com.oraclecloud.virtualnetwork.createroutetable\\,\\com.oraclecloud.virtualnetwork.deleteroutetable\\,\\com.oraclecloud.virtualnetwork.updateroutetable\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `Route Table` Changes (if any)4. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Networking` and Event Types: `Route Table – Change Compartment`, `Route Table – Create`, ` Route Table - Delete` and `Route Table - Update`5. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:**1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.virtualnetwork.changeroutetablecompartmentcom.oraclecloud.virtualnetwork.createroutetablecom.oraclecloud.virtualnetwork.deleteroutetablecom.oraclecloud.virtualnetwork.updateroutetable```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.10", + "Description": "Ensure a notification is configured for security list changes", + "Checks": [ + "events_rule_security_list_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when security lists are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments, it is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "Security Lists control traffic flowing into and out of Subnets within a Virtual Cloud Network. Monitoring and alerting on changes to Security Lists will help in identifying changes to these security controls.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Networking` in the Service Name Drop-down and selecting `Security List – Change Compartment`, `Security List – Create`, `Security List - Delete` and `Security List – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending Notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic-id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.virtualnetwork.changesecuritylistcompartment\\,\\com.oraclecloud.virtualnetwork.createsecuritylist\\,\\com.oraclecloud.virtualnetwork.deletesecuritylist\\,\\com.oraclecloud.virtualnetwork.updatesecuritylist\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `Security List` Changes (if any)4. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Networking` and Event Types: `Security List – Change Compartment`, `Security List – Create`, `Security List - Delete` and `Security List – Update`5. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:**1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.virtualnetwork.changesecuritylistcompartmentcom.oraclecloud.virtualnetwork.createsecuritylistcom.oraclecloud.virtualnetwork.deletesecuritylistcom.oraclecloud.virtualnetwork.updatesecuritylist```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.11", + "Description": "Ensure a notification is configured for network security group changes", + "Checks": [ + "events_rule_network_security_group_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when network security groups are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments, it is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "Network Security Groups control traffic flowing between Virtual Network Cards attached to Compute instances. Monitoring and alerting on changes to Network Security Groups will help in identifying changes these security controls.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Networking` in the Service Name Drop-down and selecting `Network Security Group – Change Compartment`, `Network Security Group – Create`, `Network Security Group - Delete` and `Network Security Group – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending Notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: } ] }, condition:{\\eventType\\:[\\com.oraclecloud.virtualnetwork.changenetworksecuritygroupcompartment\\,\\com.oraclecloud.virtualnetwork.createnetworksecuritygroup\\,\\com.oraclecloud.virtualnetwork.deletenetworksecuritygroup\\,\\com.oraclecloud.virtualnetwork.updatenetworksecuritygroup\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `Network Security Group` Changes (if any)4. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Networking` and Event Types: `Network Security Group – Change Compartment`, `Network Security Group – Create`, `Network Security Group - Delete` and `Network Security Group – Update`5. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:**1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following conditions are present:```com.oraclecloud.virtualnetwork.changenetworksecuritygroupcompartmentcom.oraclecloud.virtualnetwork.createnetworksecuritygroupcom.oraclecloud.virtualnetwork.deletenetworksecuritygroupcom.oraclecloud.virtualnetwork.updatenetworksecuritygroup```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.12", + "Description": "Ensure a notification is configured for changes to network gateways", + "Checks": [ + "events_rule_network_gateway_changes" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when Network Gateways are created, updated, deleted, attached, detached, or moved. This recommendation includes Internet Gateways, Dynamic Routing Gateways, Service Gateways, Local Peering Gateways, and NAT Gateways. Event Rules are compartment scoped and will detect events in child compartments, it is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "Network Gateways act as routers between VCNs and the Internet, Oracle Services Networks, other VCNS, and on-premise networks.Monitoring and alerting on changes to Network Gateways will help in identifying changes to the security posture.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Networking` in the Service Name Drop-down and selecting:```DRG – CreateDRG – DeleteDRG – UpdateDRG Attachment – CreateDRG Attachment – DeleteDRG Attachment – UpdateInternet Gateway – CreateInternet Gateway – DeleteInternet Gateway – UpdateInternet Gateway – Change CompartmentLocal Peering Gateway – CreateLocal Peering Gateway – Delete EndLocal Peering Gateway – UpdateLocal Peering Gateway – Change CompartmentNAT Gateway – CreateNAT Gateway – DeleteNAT Gateway – UpdateNAT Gateway – Change CompartmentService Gateway – CreateService Gateway – Delete EndService Gateway – UpdateService Gateway – Attach ServiceService Gateway – Detach ServiceService Gateway – Change Compartment```6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending Notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: } ] }, condition:{\\eventType\\:[\\com.oraclecloud.virtualnetwork.createdrg\\,\\com.oraclecloud.virtualnetwork.deletedrg\\,\\com.oraclecloud.virtualnetwork.updatedrg\\,\\com.oraclecloud.virtualnetwork.createdrgattachment\\,\\com.oraclecloud.virtualnetwork.deletedrgattachment\\,\\com.oraclecloud.virtualnetwork.updatedrgattachment\\,\\com.oraclecloud.virtualnetwork.changeinternetgatewaycompartment\\,\\com.oraclecloud.virtualnetwork.createinternetgateway\\,\\com.oraclecloud.virtualnetwork.deleteinternetgateway\\,\\com.oraclecloud.virtualnetwork.updateinternetgateway\\,\\com.oraclecloud.virtualnetwork.changelocalpeeringgatewaycompartment\\,\\com.oraclecloud.virtualnetwork.createlocalpeeringgateway\\,\\com.oraclecloud.virtualnetwork.deletelocalpeeringgateway.end\\,\\com.oraclecloud.virtualnetwork.updatelocalpeeringgateway\\,\\com.oraclecloud.natgateway.changenatgatewaycompartment\\,\\com.oraclecloud.natgateway.createnatgateway\\,\\com.oraclecloud.natgateway.deletenatgateway\\,\\com.oraclecloud.natgateway.updatenatgateway\\,\\com.oraclecloud.servicegateway.attachserviceid\\,\\com.oraclecloud.servicegateway.changeservicegatewaycompartment\\,\\com.oraclecloud.servicegateway.createservicegateway\\,\\com.oraclecloud.servicegateway.deleteservicegateway.end\\,\\com.oraclecloud.servicegateway.detachserviceid\\,\\com.oraclecloud.servicegateway.updateservicegateway\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `Network Gateways` Changes (if any)4. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Networking` and Event Types: ```DRG – CreateDRG – DeleteDRG – UpdateDRG Attachment – CreateDRG Attachment – DeleteDRG Attachment – UpdateInternet Gateway – CreateInternet Gateway – DeleteInternet Gateway – UpdateInternet Gateway – Change CompartmentLocal Peering Gateway – CreateLocal Peering Gateway – Delete EndLocal Peering Gateway – UpdateLocal Peering Gateway – Change CompartmentNAT Gateway – CreateNAT Gateway – DeleteNAT Gateway – UpdateNAT Gateway – Change CompartmentService Gateway – CreateService Gateway – Delete EndService Gateway – UpdateService Gateway – Attach ServiceService Gateway – Detach ServiceService Gateway – Change Compartment```5. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:**1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.virtualnetwork.createdrgcom.oraclecloud.virtualnetwork.deletedrgcom.oraclecloud.virtualnetwork.updatedrgcom.oraclecloud.virtualnetwork.createdrgattachmentcom.oraclecloud.virtualnetwork.deletedrgattachmentcom.oraclecloud.virtualnetwork.updatedrgattachmentcom.oraclecloud.virtualnetwork.changeinternetgatewaycompartmentcom.oraclecloud.virtualnetwork.createinternetgatewaycom.oraclecloud.virtualnetwork.deleteinternetgatewaycom.oraclecloud.virtualnetwork.updateinternetgatewaycom.oraclecloud.virtualnetwork.changelocalpeeringgatewaycompartmentcom.oraclecloud.virtualnetwork.createlocalpeeringgatewaycom.oraclecloud.virtualnetwork.deletelocalpeeringgateway.endcom.oraclecloud.virtualnetwork.updatelocalpeeringgatewaycom.oraclecloud.natgateway.changenatgatewaycompartmentcom.oraclecloud.natgateway.createnatgatewaycom.oraclecloud.natgateway.deletenatgatewaycom.oraclecloud.natgateway.updatenatgatewaycom.oraclecloud.servicegateway.attachserviceidcom.oraclecloud.servicegateway.changeservicegatewaycompartmentcom.oraclecloud.servicegateway.createservicegatewaycom.oraclecloud.servicegateway.deleteservicegateway.endcom.oraclecloud.servicegateway.detachserviceidcom.oraclecloud.servicegateway.updateservicegateway```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "" + } + ] + }, + { + "Id": "4.13", + "Description": "Ensure VCN flow logging is enabled for all subnets", + "Checks": [ + "network_vcn_subnet_flow_logs_enabled" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "VCN flow logs record details about traffic that has been accepted or rejected based on the security list rule.", + "RationaleStatement": "Enabling VCN flow logs enables you to monitor traffic flowing within your virtual network and can be used to detect anomalous traffic.", + "ImpactStatement": "Enabling VCN flow logs will not affect the performance of your virtual network but it will generate additional use of object storage that should be controlled via object lifecycle management.By default, VCN flow logs are stored for 30 days in object storage. Users can specify a longer retention period.", + "RemediationProcedure": "**From Console:**First, if a Capture filter has not already been created, create a Capture Filter by the following steps:1. Go to the Network Command Center page (https://cloud.oracle.com/networking/network-command-center)2. Click 'Capture filters'3. Click 'Create Capture filter'4. Type a name for the Capture filter in the Name box.5. Select 'Flow log capture filter'6. For `Sample rating` select `100%`7. Scroll to `Rules`8. For `Traffic disposition` select `All`9. For `Include/Exclude` select `Include`10. Level `Source IPv4 CIDR or IPv6 prefix` and `Destination IPv4 CIDR or IPv6 prefix` empty11. For `IP protocol` select `Include`12. Click `Create Capture filter`Second, enable VCN flow logging for your VCN or subnet(s) by the following steps:1. Go to the Logs page (https://cloud.oracle.com/logging/logs)2. Click the `Enable Service Log` button in the middle of the screen.3. Select the relevant resource compartment.4. Select `Virtual Cloud Networks - Flow logs` from the Service drop down menu.5. Select the relevant resource level from the resource drop down menu either `VCN` or `subnet`.5. Select the relevant resource from the resource drop down menu.6. Select the from the Log Category drop down menu that either `Flow Logs - subnet records` or `Flow Logs - vcn records`.7. Select the Capture filter from above7. Type a name for your flow logs in the Log Name text box.7. Select the Compartment for the Log Location8. Select the Log Group for the Log Location or Click `Create New Group` to create a new log group8. Click the Enable Log button in the lower left-hand corner.", + "AuditProcedure": "**From Console (For Logging enabled Flow logs):**1. Go to the Virtual Cloud Network (VCN) page (https://cloud.oracle.com/networking/vcns)2. Select the Compartment 3. Click on the name of each VCN4. Click on each subnet within the VCN5. Under Resources click on Logs or the Monitoring tab6. Verify that there is a log enabled for the subnet7. Click the `Log Name`8. Verify `Flowlogs Capture Filter` is set to `No filter (collecting all logs)`9. If there is a Capture filter click the 'Capture Filter Name'10. Click `Edit`11. Verify Sampling rate is `100%`12. Click `Cancel`13. Verify there is a in the Rules list that is: `Enabled, Traffic disposition: All, Include/Exclude: Include, Source CIDR: Any, Destination CIDR: Any, IP Protocol: All`**From Console (For Network Command Center Enabled Flow logs):**1. Go to the Network Command Center page (https://cloud.oracle.com/networking/network-command-center)2. Click on Flow Logs3. Click on the Flow log `Name`4. Click `Edit`5. Verify Sampling rate is `100%` 6. Click `Cancel`7. Verify there is a in the Rules list that is: `Enabled, Traffic disposition: All, Include/Exclude: Include, Source CIDR: Any, Destination CIDR: Any, IP Protocol: All`", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en/solutions/oci-aggregate-logs-siem/index.html#GUID-601E052A-8A8E-466B-A8A8-2BBBD3B80B6D" + } + ] + }, + { + "Id": "4.14", + "Description": "Ensure Cloud Guard is enabled in the root compartment of the tenancy", + "Checks": [ + "cloudguard_enabled" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Cloud Guard detects misconfigured resources and insecure activity within a tenancy and provides security administrators with the visibility to resolve these issues. Upon detection, Cloud Guard can suggest, assist, or take corrective actions to mitigate these issues. Cloud Guard should be enabled in the root compartment of your tenancy with the default configuration, activity detectors and responders.", + "RationaleStatement": "Cloud Guard provides an automated means to monitor a tenancy for resources that are configured in an insecure manner as well as risky network activity from these resources.", + "ImpactStatement": "There is no performance impact when enabling the above described features, but additional IAM policies will be required.", + "RemediationProcedure": "**From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console.2. Click `Cloud Guard` from the Services submenu.3. Click `Enable Cloud Guard`.4. Click `Create Policy`.5. Click `Next`.6. Under `Reporting Region`, select a region.7. Under `Compartments To Monitor`, choose `Select Compartment`.8. Under `Select Compartments`, select the `root` compartment.9. Under `Configuration Detector Recipe`, select `OCI Configuration Detector Recipe (Oracle Managed)`.10. Under `Activity Detector Recipe`, select `OCI Activity Detector Recipe (Oracle Managed)`.11. Click `Enable`.**From CLI:**1. Create OCI IAM Policy for Cloud Guard```oci iam policy create --compartment-id '' --name 'CloudGuardPolicies' --description 'Cloud Guard Access Policy' --statements '[ allow service cloudguard to read vaults in tenancy, allow service cloudguard to read keys in tenancy, allow service cloudguard to read compartments in tenancy, allow service cloudguard to read tenancies in tenancy, allow service cloudguard to read audit-events in tenancy, allow service cloudguard to read compute-management-family in tenancy, allow service cloudguard to read instance-family in tenancy, allow service cloudguard to read virtual-network-family in tenancy, allow service cloudguard to read volume-family in tenancy, allow service cloudguard to read database-family in tenancy, allow service cloudguard to read object-family in tenancy, allow service cloudguard to read load-balancers in tenancy, allow service cloudguard to read users in tenancy, allow service cloudguard to read groups in tenancy, allow service cloudguard to read policies in tenancy, allow service cloudguard to read dynamic-groups in tenancy, allow service cloudguard to read authentication-policies in tenancy ]'```2. Enable Cloud Guard in root compartment```oci cloud-guard configuration update --reporting-region '' --compartment-id '' --status 'ENABLED'```", + "AuditProcedure": "**From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console.2. Click `Cloud Guard` from the Services submenu.3. View if `Cloud Guard` is enabled**From CLI:**1. Retrieve the `Cloud Guard` status from the console```oci cloud-guard configuration get --compartment-id --query 'data.status'```2. Ensure the returned value is ENABLED`", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en-us/iaas/Content/General/Concepts/regions.htm" + } + ] + }, + { + "Id": "4.15", + "Description": "Ensure a notification is configured for Oracle Cloud Guard problems detected", + "Checks": [ + "events_rule_cloudguard_problems" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Cloud Guard detects misconfigured resources and insecure activity within a tenancy and provides security administrators with the visibility to resolve these issues. Upon detection, Cloud Guard generates a Problem. It is recommended to setup an Event Rule and Notification that gets triggered when Oracle Cloud Guard Problems are created, dismissed or remediated. Event Rules are compartment scoped and will detect events in child compartments. It is recommended to create the Event rule at the root compartment level.", + "RationaleStatement": "Cloud Guard provides an automated means to monitor a tenancy for resources that are configured in an insecure manner as well as risky network activity from these resources. Monitoring and alerting on Problems detected by Cloud Guard will help in identifying changes to the security posture.", + "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)1. Select the compartment that should host the rule1. Click Create Rule1. Provide a Display Name and Description1. Create a Rule Condition by selecting Cloud Guard in the Service Name Drop-down and selecting: `Detected – Problem`, `Remediated – Problem`, and `Dismissed - Problem`1. In the Actions section select Notifications as Action Type1. Select the Compartment that hosts the Topic to be used.1. Select the Topic to be used1. Optionally add Tags to the Rule1. Click Create Rule**From CLI:**1. Find the topic-id of the topic the Event Rule should use for sending Notifications by using the topic name and Compartment OCID```oci ons topic list --compartment-id= --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```1. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\ com.oraclecloud.cloudguard.problemdetected\\,\\ com.oraclecloud.cloudguard.problemdismissed\\,\\ com.oraclecloud.cloudguard.problemremediated\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: compartment OCID}```1. Create the actual event rule```oci events rule create --from-json file://event_rule.json```1. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)1. Select the Compartment that hosts the rules1. Find and click the Rule that handles Cloud Guard Changes (if any)1. Click the Edit Rule button and verify that the RuleConditions section contains a condition for the Service Cloud Guard and Event Types: Detected – Problem, Remediated – Problem, and Dismissed - Problem1. Verify that in the Actions section the Action Type contains: Notifications and that a valid Topic is referenced.**From CLI:**1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id= --query data [?\\display-name\\==''].{id:id} --output table```1. List the details of a specific Event Rule based on the OCID of the rule.1. In the JSON output locate the Conditions key-value pair and verify that the following Conditions are present: ```com.oraclecloud.cloudguard.problemdetected,com.oraclecloud.cloudguard.problemdismissed,com.oraclecloud.cloudguard.problemremediated```1. Verify the value of the is-enabled attribute is true1. In the JSON output verify that actionType is ONS and locate the topic-id1. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id= --query data.{name:name} --output table```", + "AdditionalInformation": "'- Your tenancy might have a different Cloud Reporting region than your home region.- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "https://docs.oracle.com/en-us/iaas/cloud-guard/using/export-notifs-config.htm" + } + ] + }, + { + "Id": "4.16", + "Description": "Ensure customer created Customer Managed Key (CMK) is rotated at least annually", + "Checks": [ + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Oracle Cloud Infrastructure Vault securely stores master encryption keys that protect your encrypted data. You can use the Vault service to rotate keys to generate new cryptographic material. Periodically rotating keys limits the amount of data encrypted by one key version.", + "RationaleStatement": "Rotating keys annually limits the data encrypted under one key version. Key rotation thereby reduces the risk in case a key is ever compromised.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Login into OCI Console.2. Select `Identity & Security` from the Services menu.3. Select `Vault`.4. Click on the individual Vault under the Name heading.5. Click on the menu next to the time created.6. Click `Rotate Key`**From CLI:**1. Execute the following:```oci kms management key rotate --key-id --endpoint ```", + "AuditProcedure": "**From Console:**1. Login into OCI Console.2. Select `Identity & Security` from the Services menu.3. Select `Vault`.4. Click on the individual Vault under the Name heading.5. Ensure the date of each Master Encryption key under the `Created` column of the Master Encryption key is no more than 365 days old, and that the key is in the `ENABLED` state6. Repeat for all Vaults in all compartments**From CLI:**1. Execute the following for each Vault in each compartment```oci kms management key list --compartment-id '' --endpoint '' --all --query data[*].[\\time-created\\,\\display-name\\,\\lifecycle-state\\]```2. Ensure the date of the Master Encryption key is no more than 365 days old and is also in the `ENABLED` state.", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "4.17", + "Description": "Ensure write level Object Storage logging is enabled for all buckets", + "Checks": [ + "objectstorage_bucket_logging_enabled" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Object Storage write logs will log all write requests made to objects in a bucket.", + "RationaleStatement": "Enabling Object Storage write logging ensures the `requestAction` property will show values like `PUT`, `POST`, or `DELETE`, providing increased visibility into changes made to objects within buckets.", + "ImpactStatement": "Enabling object storage write logging does not impact object storage performance, but will consume additional storage for the logs themselves. By default, logs are retained for 30 days, but users may configure longer retention periods. To manage costs, implement object lifecycle policies to remove unneeded logs as appropriate.", + "RemediationProcedure": "**From Console:**1. Log in to the OCI Console.2. Go to [Object Storage Buckets](https://cloud.oracle.com/object-storage/buckets).3. Click the name of the bucket to configure.4. In the Resource menu, click `Monitoring`.5. Scroll to the `Logs` section.6. Find `Write Access Events` and click the three dots `...` at the end of the row.7. Click `Enable Log`.8. Choose an existing log group or select `Create new group`.9. Configure the log name.10. Set a desired log retention period (in months).11. Click `Enable log`.**From CLI:***If a log group does not exist:*1. Create a log group:```shoci logging log-group create --compartment-id --display-name --description ```2. Check work request status:```shoci logging work-request get --work-request-id ```Wait until status is `SUCCEEDED`.*To enable write logging for your bucket(s):*3. Get the Log Group OCID:```shoci logging log-group list --compartment-id --query \"data[?\\display-name==''].id\" --raw-output```4. Create `config.json` with the following content (update all placeholders):```json{ \"compartment-id\": \"\", \"source\": { \"resource\": \"\", \"service\": \"ObjectStorage\", \"source-type\": \"OCISERVICE\", \"category\": \"write\" }}```5. Create the service log:```shoci logging log create --log-group-id --display-name --log-type SERVICE --is-enabled TRUE --configuration file://config.json```6. Confirm creation with work request id:```shoci logging work-request get --work-request-id ```Look for status `SUCCEEDED`.", + "AuditProcedure": "**From Console:**1. Log in to the OCI Console.2. Go to [Object Storage Buckets](https://cloud.oracle.com/object-storage/buckets).3. Click on a bucket's name.4. Select `Monitoring` from the Resource menu.5. Scroll to `Logs` and ensure the `Status` for `Write Access Events` is `Active`.**From CLI:**1. List all buckets in the compartment:```shoci os bucket list --compartment-id ```2. Find the Log Group OCID:```shoci logging log-group list --compartment-id --query \"data[?\\display-name=='']\"```3. List logs associated with the specific bucket:```shoci logging log list --log-group-id --query \"data[?configuration.source.resource=='']\"```4. Ensure a log entry exists for the target bucket's name.", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "4.18", + "Description": "Ensure a notification is configured for Local OCI User Authentication", + "Checks": [ + "events_rule_local_user_authentication" + ], + "Attributes": [ + { + "Section": "4. Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that an Event Rule and Notification be set up when a user in the via OCI local authentication. Event Rules are compartment-scoped and will detect events in child compartments. This Event rule is required to be created at the root compartment level.", + "RationaleStatement": "Users should rarely use OCI local authenticated and be authenticated via organizational standard Identity providers, not local credentials. Access in this matter would represent a break glass activity and should be monitored to see if changes made impact the security posture.", + "ImpactStatement": "There is no performance impact when enabling the above-described features but depending on the amount of notifications sent per month there may be a cost associated.", + "RemediationProcedure": "From Console:1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Root compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Identity SignOn` in the Service Name Drop-down and selecting `Interactive Login`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`From CLI:1. Find the `topic-id` of the topic the Event Rule should use for sending notifications by using the topic `name` and `Tenancy OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.identitysignon.interactivelogin\\,data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "From Console:1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Root Compartment `that hosts the rules3. Click the `Rule` that handles `Identity SignOn` Changes (if any)4. Click the `Edit Rule` button and verify that the `RuleCondition`s section contains a condition `Event Type` for the Service `Identity SignOn` and Event Types: `Interactive Login `5. On the Action Type contains: `Notifications` and that a valid Topic is referenced.From CLI:1. Find the OCID of the specific Event Rule based on Display Name and Tenancy OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.identitysignon.interactivelogin```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", + "References": "https://docs.oracle.com/en-us/iaas/Content/Security/Reference/iam_security_topic-IAM_Federation.htm#IAM_Federation" + } + ] + }, + { + "Id": "5.1.1", + "Description": "Ensure no Object Storage buckets are publicly visible", + "Checks": [ + "objectstorage_bucket_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "5. Storage", + "SubSection": "5.1 Object Storage", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "A bucket is a logical container for storing objects. It is associated with a single compartment that has policies that determine what action a user can perform on a bucket and on all the objects in the bucket. By Default a newly created bucket is private. It is recommended that no bucket be publicly accessible.", + "RationaleStatement": "Removing unfettered reading of objects in a bucket reduces an organization's exposure to data loss.", + "ImpactStatement": "For updating an existing bucket, care should be taken to ensure objects in the bucket can be accessed through either IAM policies or pre-authenticated requests.", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above. 2. For each `bucket` in the returned results, click the Bucket `Display Name`3. Click `Edit Visibility`3. Select `Private`4. Click `Save Changes`**From CLI:**1. Follow the audit procedure2. For each of the `buckets` identified, execute the following command:```oci os bucket update --bucket-name --public-access-type NoPublicAccess```", + "AuditProcedure": "**From Console:**1. Login into the OCI Console2. Click in the search bar at the top of the screen.3. Type `Advanced Resource Query` and click `enter`.4. Click the `Advanced Resource Query` button in the upper right of the screen.5. Enter the following query in the query box:```querybucket resourceswhere (publicAccessType == 'ObjectRead') || (publicAccessType == 'ObjectReadWithoutList')```6. Ensure query returns no results**From CLI:**1. Execute the following command:```oci search resource structured-search --query-text query bucket resourceswhere (publicAccessType == 'ObjectRead') || (publicAccessType == 'ObjectReadWithoutList')```2. Ensure query returns no results**Cloud Guard**To Enable Cloud Guard Auditing:Ensure Cloud Guard is enabled in the root compartment of the tenancy. For more information about enabling Cloud Guard, please look at the instructions included in Recommendation 3.15. **From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console. 2. Click `Cloud Guard` from the “Services” submenu.3. Click `Detector Recipes` in the Cloud Guard menu.4. Click `OCI Configuration Detector Recipe (Oracle Managed)` under the Recipe Name column.5. Find Bucket is public in the Detector Rules column.6. Verify that the Bucket is public Detector Rule is Enabled.**From CLI:**1. Verify the Bucket is public Detector Rule in Cloud Guard is enabled to generate Problems if Object Storage Buckets are configured to be accessible over the public Internet with the following command:```oci cloud-guard detector-recipe-detector-rule get --detector-recipe-id --detector-rule-id BUCKET_IS_PUBLIC```", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/managingbuckets.htm" + } + ] + }, + { + "Id": "5.1.2", + "Description": "Ensure Object Storage Buckets are encrypted with a Customer Managed Key (CMK)", + "Checks": [ + "objectstorage_bucket_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "5. Storage", + "SubSection": "5.1 Object Storage", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Oracle Object Storage buckets support encryption with a Customer Managed Key (CMK). By default, Object Storage buckets are encrypted with an Oracle managed key.", + "RationaleStatement": "Encryption of Object Storage buckets with a Customer Managed Key (CMK) provides an additional level of security on your data by allowing you to manage your own encryption key lifecycle management for the bucket.", + "ImpactStatement": "Encrypting with a Customer Managed Keys requires a Vault and a Customer Master Key. In addition, you must authorize Object Storage service to use keys on your behalf.Required Policy:```Allow service objectstorage-, to use keys in compartment where target.key.id = ''```", + "RemediationProcedure": "**From Console:**1. Go to [https://cloud.oracle.com/object-storage/buckets](https://cloud.oracle.com/object-storage/buckets)1. Click on an individual bucket under the Name heading.1. Click `Assign` next to `Encryption Key: Oracle managed key`.1. Select a `Vault`1. Select a `Master Encryption Key`1. Click `Assign`**From CLI:**1. Execute the following command```oci os bucket update --bucket-name --kms-key-id ```", + "AuditProcedure": "**From Console:**1. Go to [https://cloud.oracle.com/object-storage/buckets](https://cloud.oracle.com/object-storage/buckets)1. Click on an individual bucket under the Name heading.1. Ensure that the `Encryption Key` is not set to `Oracle managed key`.1. Repeat for each compartment**From CLI:**1. Execute the following command```oci os bucket get --bucket-name ```2. Ensure `kms-key-id` is not `null`**Cloud Guard**To Enable Cloud Guard Auditing:Ensure Cloud Guard is enabled in the root compartment of the tenancy. For more information about enabling Cloud Guard, please look at the instructions included in Recommendation 3.15. **From Console:**1. Type `Cloud Guard` into the Search box at the top of the Console. 2. Click `Cloud Guard` from the “Services” submenu.3. Click `Detector Recipes` in the Cloud Guard menu.4. Click `OCI Configuration Detector Recipe (Oracle Managed)` under the Recipe Name column.5. Find Object Storage bucket is encrypted with Oracle-managed key in the Detector Rules column.6. Verify that the Object Storage bucket is encrypted with Oracle-managed key Detector Rule is Enabled.**From CLI:**1. Verify the Object Storage bucket is encrypted with Oracle-managed key Detector Rule in Cloud Guard is enabled to generate Problems if Object Storage Buckets are configured without a customer managed key with the following command:```oci cloud-guard detector-recipe-detector-rule get --detector-recipe-id --detector-rule-id BUCKET_ENCRYPTED_WITH_ORACLE_MANAGED_KEY```", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en/solutions/oci-best-practices/protect-data-rest1.html#GUID-9C0F713E-4C67-43C6-80CA-525A6AB221F1:https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/encryption.htm" + } + ] + }, + { + "Id": "5.1.3", + "Description": "Ensure Versioning is Enabled for Object Storage Buckets", + "Checks": [ + "objectstorage_bucket_versioning_enabled" + ], + "Attributes": [ + { + "Section": "5. Storage", + "SubSection": "5.1 Object Storage", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "A bucket is a logical container for storing objects. Object versioning is enabled at the bucket level and is disabled by default upon creation. Versioning directs Object Storage to automatically create an object version each time a new object is uploaded, an existing object is overwritten, or when an object is deleted. You can enable object versioning at bucket creation time or later.", + "RationaleStatement": "Versioning object storage buckets provides for additional integrity of your data. Management of data integrity is critical to protecting and accessing protected data. Some customers want to identify object storage buckets without versioning in order to apply their own data lifecycle protection and management policy.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above.2. For each bucket in the returned results, click the Bucket Display Name3. Click `Edit` next to `Object Versioning: Disabled`4. Click `Enable Versioning`**From CLI:**1. Follow the audit procedure2. For each of the buckets identified, execute the following command:```oci os bucket update --bucket-name --versioning Enabled```", + "AuditProcedure": "**From Console:**1. Login to OCI Console.2. Select `Storage` from the Services menu.3. Select `Buckets` from under the `Object Storage & Archive Storage` section.4. Click on an individual bucket under the Name heading.5. Ensure that the `Object Versioning` is set to Enabled.6. Repeat for each compartment**From CLI:**1. Execute the following command:```for region in $(oci iam region-subscription list --all | jq -r '.data[] | .region-name')do echo Enumerating region $region for compid in $(oci iam compartment list --include-root --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id') do echo Enumerating compartment $compid for bkt in $(oci os bucket list --compartment-id $compid --region $region 2>/dev/null | jq -r '.data[] | .name') do output=$(oci os bucket get --bucket-name $bkt --region $region 2>/dev/null | jq -r '.data | select(.versioning == Disabled).name') if [ ! -z $output ]; then echo $output; fi done donedone```2. Ensure no results are returned.", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/usingversioning.htm:https://docs.oracle.com/en-us/iaas/api/#/en/objectstorage/20160918/Bucket/GetBucket" + } + ] + }, + { + "Id": "5.2.1", + "Description": "Ensure Block Volumes are encrypted with Customer Managed Keys (CMK)", + "Checks": [ + "blockstorage_block_volume_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "5. Storage", + "SubSection": "5.2 Block Volumes", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Oracle Cloud Infrastructure Block Volume service lets you dynamically provision and manage block storage volumes. By default, the Oracle service manages the keys that encrypt block volumes. Block Volumes can also be encrypted using a customer managed key.Terminated Block Volumes cannot be recovered and any data on a terminated volume is permanently lost. However, Block Volumes can exist in a terminated state within the OCI Portal and CLI for some time after deleting. As such, any Block Volumes in this state should not be considered when assessing this policy.", + "RationaleStatement": "Encryption of block volumes provides an additional level of security for your data. Management of encryption keys is critical to protecting and accessing protected data. Customers should identify block volumes encrypted with Oracle service managed keys in order to determine if they want to manage the keys for certain volumes and then apply their own key lifecycle management to the selected block volumes.", + "ImpactStatement": "Encrypting with a Customer Managed Key requires a Vault and a Customer Master Key. In addition, you must authorize the Block Volume service to use the keys you create.Required IAM Policy:```Allow service blockstorage to use keys in compartment where target.key.id = ''```", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above.2. For each block volume returned, click the link under Display name.3. If the value for `Encryption Key` is `Oracle-managed key`, click `Assign` next to `Oracle-managed key`.4. Select a `Vault Compartment` and `Vault`.5. Select a `Master Encryption Key Compartment` and `Master Encryption key`.6. Click `Assign`.**From CLI:**1. Follow the audit procedure.2. For each `boot volume` identified, get the OCID.3. Execute the following command:```oci bv volume-kms-key update –volume-id --kms-key-id ```", + "AuditProcedure": "**From Console:**1. Login to the OCI Console.2. Click the search bar at the top of the screen.3. Type 'Advanced Resource Query' and press return.4. Click `Advanced resource query`.5. Enter the following query in the query box:```query volume resources```6. For each block volume returned, click the link under `Display name`.7. Ensure the value for `Encryption Key` is not `Oracle-managed key`.8. Repeat for other subscribed regions.**From CLI:**1. Execute the following command:```for region in $(oci iam region-subscription list --all| jq -r '.data[] | .region-name')do echo Enumerating region: $region for compid in `oci iam compartment list --compartment-id-in-subtree TRUE 2>/dev/null | jq -r '.data[] | .id'` do echo Enumerating compartment: $compid for bvid in `oci bv volume list --compartment-id $compid --region $region 2>/dev/null | jq -r '.data[] | select(.kms-key-id == null).id'` do output=`oci bv volume get --volume-id $bvid --region $region --query=data.{name:\\display-name\\,id:id} --output table 2>/dev/null` if [ ! -z $output ]; then echo $output; fi done done done```2. Ensure the query returns no results.", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en/solutions/oci-best-practices/protect-data-rest1.html#GUID-BA1F5A20-8C78-49E3-8183-927F0CC6F6CC:https://docs.oracle.com/en-us/iaas/Content/Block/Concepts/overview.htm" + } + ] + }, + { + "Id": "5.2.2", + "Description": "Ensure boot volumes are encrypted with Customer Managed Key (CMK)", + "Checks": [ + "blockstorage_boot_volume_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "5. Storage", + "SubSection": "5.2 Block Volumes", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "When you launch a virtual machine (VM) or bare metal instance based on a platform image or custom image, a new boot volume for the instance is created in the same compartment. That boot volume is associated with that instance until you terminate the instance. By default, the Oracle service manages the keys that encrypt this boot volume. Boot Volumes can also be encrypted using a customer managed key.", + "RationaleStatement": "Encryption of boot volumes provides an additional level of security for your data. Management of encryption keys is critical to protecting and accessing protected data. Customers should identify boot volumes encrypted with Oracle service managed keys in order to determine if they want to manage the keys for certain boot volumes and then apply their own key lifecycle management to the selected boot volumes.", + "ImpactStatement": "Encrypting with a Customer Managed Keys requires a Vault and a Customer Master Key. In addition, you must authorize the Boot Volume service to use the keys you create.Required IAM Policy:```Allow service Bootstorage to use keys in compartment where target.key.id = ''```", + "RemediationProcedure": "**From Console:**1. Follow the audit procedure above.2. For each Boot Volume in the returned results, click the Boot Volume name3. Click `Assign` next to `Encryption Key`4. Select the `Vault Compartment` and `Vault`5. Select the `Master Encryption Key Compartment` and `Master Encryption key`6. Click `Assign`**From CLI:**1. Follow the audit procedure.2. For each `boot volume` identified get its OCID. Execute the following command:```oci bv boot-volume-kms-key update --boot-volume-id --kms-key-id ```", + "AuditProcedure": "**From Console:**1. Login into the OCI Console2. Click in the search bar, top of the screen.3. Type Advanced Resource Query and click enter.4. Click the `Advanced Resource Query` button in the upper right of the screen.5. Enter the following query in the query box:```query bootvolume resources```6. For each boot volume returned click on the link under `Display name`7. Ensure `Encryption Key` does not say `Oracle managed key`8. Repeat for other subscribed regions**From CLI:**1. Execute the following command:```for region in `oci iam region list | jq -r '.data[] | .name'`; do for bvid in `oci search resource structured-search --region $region --query-text query bootvolume resources 2>/dev/null | jq -r '.data.items[] | .identifier'` do output=`oci bv boot-volume get --boot-volume-id $bvid 2>/dev/null | jq -r '.data | select(.kms-key-id == null).id'` if [ ! -z $output ]; then echo $output; fi done done```2. Ensure query returns no results.", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en/solutions/oci-best-practices/protect-data-rest1.html#GUID-BA1F5A20-8C78-49E3-8183-927F0CC6F6CC" + } + ] + }, + { + "Id": "5.3.1", + "Description": "Ensure File Storage Systems are encrypted with Customer Managed Keys (CMK)", + "Checks": [ + "filestorage_file_system_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "5. Storage", + "SubSection": "5.3 File Storage Service", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Oracle Cloud Infrastructure File Storage service (FSS) provides a durable, scalable, secure, enterprise-grade network file system. By default, the Oracle service manages the keys that encrypt FSS file systems. FSS file systems can also be encrypted using a customer managed key.", + "RationaleStatement": "Encryption of FSS systems provides an additional level of security for your data. Management of encryption keys is critical to protecting and accessing protected data. Customers should identify FSS file systems that are encrypted with Oracle service managed keys in order to determine if they want to manage the keys for certain FSS file systems and then apply their own key lifecycle management to the selected FSS file systems.", + "ImpactStatement": "Encrypting with a Customer Managed Keys requires a Vault and a Customer Master Key. In addition, you must authorize the File Storage service to use the keys you create.Required IAM Policy:```Allow service FssOc1Prod to use keys in compartment where target.key.id = ''```", + "RemediationProcedure": "From Console:1. Follow the audit procedure above.2. For each File Storage System in the returned results, click the File System Storage3. Click `Edit` next to `Encryption Key`4. Select `Encrypt using customer-managed keys`5. Select the `Vault Compartment` and `Vault`6. Select the `Master Encryption Key Compartment` and `Master Encryption key`7. Click `Save Changes`**From CLI:**1. Follow the audit procedure.2. For each `File Storage System` identified get its OCID. Execute the following command:```oci bv volume-kms-key update –volume-id --kms-key-id ```", + "AuditProcedure": "**From Console:**1. Login into the OCI Console2. Click in the search bar, top of the screen.3. Type Advanced Resource Query and click enter.4. Click the `Advanced Resource Query` button in the upper right of the screen.5. Enter the following query in the query box:```query filesystem resources```6. For each file storage system returned click on the link under `Display name`7. Ensure `Encryption Key` does not say `Oracle-managed key`8. Repeat for other subscribed regions**From CLI:**1. Execute the following command:```for region in `oci iam region list | jq -r '.data[] | .name'`; do for fssid in `oci search resource structured-search --region $region --query-text query filesystem resources 2>/dev/null | jq -r '.data.items[] | .identifier'` do output=`oci fs file-system get --file-system-id $fssid --region $region 2>/dev/null | jq -r '.data | select(.kms-key-id == ).id'` if [ ! -z $output ]; then echo $output; fi done done```2. Ensure query returns no results", + "AdditionalInformation": "", + "References": "https://docs.oracle.com/en/solutions/oci-best-practices/protect-data-rest1.html#GUID-BA1F5A20-8C78-49E3-8183-927F0CC6F6CC:https://docs.oracle.com/en-us/iaas/Content/File/Concepts/filestorageoverview.htm" + } + ] + }, + { + "Id": "6.1", + "Description": "Create at least one compartment in your tenancy to store cloud resources", + "Checks": [ + "identity_non_root_compartment_exists" + ], + "Attributes": [ + { + "Section": "6. Asset Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "When you sign up for Oracle Cloud Infrastructure, Oracle creates your tenancy, which is the root compartment that holds all your cloud resources. You then create additional compartments within the tenancy (root compartment) and corresponding policies to control access to the resources in each compartment. Compartments allow you to organize and control access to your cloud resources. A compartment is a collection of related resources (such as instances, databases, virtual cloud networks, block volumes) that can be accessed only by certain groups that have been given permission by an administrator.", + "RationaleStatement": "Compartments are a logical group that adds an extra layer of isolation, organization and authorization making it harder for unauthorized users to gain access to OCI resources.", + "ImpactStatement": "Once the compartment is created an OCI IAM policy must be created to allow a group to resources in the compartment otherwise only group with tenancy access will have access.", + "RemediationProcedure": "**From Console:**1. Login to OCI Console.1. Select `Identity` from the Services menu.1. Select `Compartments` from the Identity menu.1. Click `Create Compartment`1. Enter a `Name`1. Enter a `Description`1. Select the root compartment as the `Parent Compartment`1. Click `Create Compartment`**From CLI:**1. Execute the following command```oci iam compartment create --compartment-id '' --name '' --description ''```", + "AuditProcedure": "**From Console:**1. Login into the OCI Console.1. Click in the search bar, top of the screen.1. Type `Advanced Resource Query` and hit `enter`.1. Click the `Advanced Resource Query` button in the upper right of the screen.1. Enter the following query in the query box:```query compartment resourceswhere (compartmentId='' && lifecycleState='ACTIVE')```6. Ensure query returns at least one compartment in addition to the `ManagedCompartmentForPaaS` compartment**From CLI:**1. Execute the following command```oci search resource structured-search --query-text query compartment resourceswhere (compartmentId='' && lifecycleState='ACTIVE')```2. Ensure `items` are returned.", + "AdditionalInformation": "", + "References": "" + } + ] + }, + { + "Id": "6.2", + "Description": "Ensure no resources are created in the root compartment", + "Checks": [ + "identity_no_resources_in_root_compartment" + ], + "Attributes": [ + { + "Section": "6. Asset Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "When you create a cloud resource such as an instance, block volume, or cloud network, you must specify to which compartment you want the resource to belong. Placing resources in the root compartment makes it difficult to organize and isolate those resources.", + "RationaleStatement": "Placing resources into a compartment will allow you to organize and have more granular access controls to your cloud resources.", + "ImpactStatement": "Placing a resource in a compartment will impact how you write policies to manage access and organize that resource.", + "RemediationProcedure": "**From Console:**1. Follow audit procedure above.2. For each item in the returned results, click the item name.3. Then select `Move Resource` or `More Actions` then `Move Resource`.4. Select a compartment that is not the root compartment in `CHOOSE NEW COMPARTMENT`.5. Click `Move Resource`.**From CLI:**1. Follow the audit procedure above.2. For each bucket item execute the below command: ```oci os bucket update --bucket-name --compartment-id ```3. For other resources use the `change-compartment` command for the resource type:``` oci change-compartment -- --compartment-id ``` i. Example for an Autonomous Database:```oci db autonomous-database change-compartment --autonomous-database-id --compartment-id ```", + "AuditProcedure": "**From Console:**1. Login into the OCI Console.2. Click in the search bar, top of the screen.3. Type `Advance Resource Query` and hit `enter`.4. Click the `Advanced Resource Query` button in the upper right of the screen.5. Enter the following query into the query box:```query VCN, instance, bootvolume, volume, filesystem, bucket, autonomousdatabase, database, dbsystem resources where compartmentId = ''```6. Ensure query returns no results.**From CLI:**1. Execute the following command:```oci search resource structured-search --query-text query VCN, instance, volume, bootvolume, filesystem, bucket, autonomousdatabase, database, dbsystem resources where compartmentId = ''```2. Ensure query return no results.", + "AdditionalInformation": "https://docs.cloud.oracle.com/en-us/iaas/Content/GSG/Concepts/settinguptenancy.htm#Understa", + "References": "" + } + ] + } + ] +} diff --git a/prowler/lib/powershell/powershell.py b/prowler/lib/powershell/powershell.py index 8142fa8e45..4f68e7bd8a 100644 --- a/prowler/lib/powershell/powershell.py +++ b/prowler/lib/powershell/powershell.py @@ -193,10 +193,21 @@ class PowerShellSession: result = default if error_result: - logger.error(f"PowerShell error output: {error_result}") + self._process_error(error_result) return result + def _process_error(self, error_result: str) -> None: + """ + Process error output from the PowerShell command. + + Subclasses can override this to provide custom error handling. + + Args: + error_result (str): The error output from the PowerShell command. + """ + logger.error(f"PowerShell error output: {error_result}") + def json_parse_output(self, output: str) -> dict: """ Parse command execution output to JSON format. diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 91ea6c6be3..db1e4c05ff 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -1285,7 +1285,12 @@ "b2bi": { "regions": { "aws": [ + "ap-south-2", + "ap-southeast-2", + "ca-central-1", + "eu-central-1", "eu-west-1", + "eu-west-3", "us-east-1", "us-east-2", "us-west-2" @@ -1479,6 +1484,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -1539,11 +1545,16 @@ "regions": { "aws": [ "ap-northeast-1", + "ap-northeast-2", "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ca-central-1", "eu-central-1", + "eu-north-1", "eu-west-1", + "eu-west-2", + "eu-west-3", "us-east-1", "us-east-2", "us-west-2" @@ -2748,7 +2759,9 @@ "us-west-2" ], "aws-cn": [], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2794,7 +2807,9 @@ "aws-cn": [ "cn-north-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2838,7 +2853,9 @@ "us-west-2" ], "aws-cn": [], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3166,7 +3183,9 @@ "us-west-2" ], "aws-cn": [], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3212,7 +3231,9 @@ "us-west-2" ], "aws-cn": [], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3245,16 +3266,6 @@ "aws-us-gov": [] } }, - "cur": { - "regions": { - "aws": [], - "aws-cn": [ - "cn-northwest-1" - ], - "aws-eusc": [], - "aws-us-gov": [] - } - }, "customer-profiles": { "regions": { "aws": [ @@ -3395,6 +3406,7 @@ "datazone": { "regions": { "aws": [ + "af-south-1", "ap-east-1", "ap-northeast-1", "ap-northeast-2", @@ -3407,6 +3419,7 @@ "eu-central-1", "eu-central-2", "eu-north-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", @@ -4628,6 +4641,7 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "ca-west-1", "eu-central-1", @@ -5021,6 +5035,7 @@ "ap-east-1", "ap-northeast-1", "ap-northeast-2", + "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", @@ -5221,7 +5236,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5269,7 +5286,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5317,7 +5336,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5364,7 +5385,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5412,7 +5435,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5600,7 +5625,10 @@ ], "aws-cn": [], "aws-eusc": [], - "aws-us-gov": [] + "aws-us-gov": [ + "us-gov-east-1", + "us-gov-west-1" + ] } }, "greengrass": { @@ -5668,6 +5696,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -7100,6 +7129,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -7787,6 +7817,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-north-1", @@ -7853,6 +7884,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-north-1", @@ -8162,6 +8194,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -8261,16 +8294,30 @@ "neptune-graph": { "regions": { "aws": [ + "af-south-1", + "ap-east-1", "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", "ca-central-1", + "ca-west-1", "eu-central-1", + "eu-central-2", + "eu-north-1", "eu-west-1", "eu-west-2", + "eu-west-3", + "il-central-1", + "me-central-1", + "me-south-1", + "sa-east-1", "us-east-1", "us-east-2", + "us-west-1", "us-west-2" ], "aws-cn": [], @@ -8402,18 +8449,28 @@ "networkmonitor": { "regions": { "aws": [ + "af-south-1", "ap-east-1", "ap-northeast-1", "ap-northeast-2", + "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", "ca-central-1", "eu-central-1", + "eu-central-2", "eu-north-1", + "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", + "me-central-1", "me-south-1", "sa-east-1", "us-east-1", @@ -8575,6 +8632,7 @@ "regions": { "aws": [ "ap-northeast-1", + "ca-central-1", "eu-central-1", "us-east-1", "us-east-2", @@ -9146,6 +9204,7 @@ "regions": { "aws": [ "af-south-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9155,6 +9214,7 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-6", "ca-central-1", "ca-west-1", "eu-central-1", @@ -10223,6 +10283,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10233,6 +10294,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -11671,11 +11733,17 @@ "ap-south-2", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-6", "ca-central-1", + "ca-west-1", "eu-central-1", + "eu-north-1", "eu-south-2", "eu-west-1", "eu-west-2", + "me-central-1", + "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", diff --git a/prowler/providers/aws/services/iam/lib/privilege_escalation.py b/prowler/providers/aws/services/iam/lib/privilege_escalation.py index b2545dfce0..99d3e4dad9 100644 --- a/prowler/providers/aws/services/iam/lib/privilege_escalation.py +++ b/prowler/providers/aws/services/iam/lib/privilege_escalation.py @@ -18,16 +18,88 @@ from prowler.providers.aws.services.iam.lib.policy import get_effective_actions # - https://bishopfox.com/blog/privilege-escalation-in-aws # - https://github.com/RhinoSecurityLabs/Security-Research/blob/master/tools/aws-pentest-tools/aws_escalate.py # - https://rhinosecuritylabs.com/aws/aws-privilege-escalation-methods-mitigation/ +# - https://github.com/DataDog/pathfinding.cloud (AWS IAM Privilege Escalation Path Library) privilege_escalation_policies_combination = { + # IAM self-escalation and policy manipulation "OverPermissiveIAM": {"iam:*"}, "IAMPut": {"iam:Put*"}, "CreatePolicyVersion": {"iam:CreatePolicyVersion"}, "SetDefaultPolicyVersion": {"iam:SetDefaultPolicyVersion"}, + "iam:CreateAccessKey": {"iam:CreateAccessKey"}, + "iam:CreateLoginProfile": {"iam:CreateLoginProfile"}, + "iam:UpdateLoginProfile": {"iam:UpdateLoginProfile"}, + "iam:AttachUserPolicy": {"iam:AttachUserPolicy"}, + "iam:AttachGroupPolicy": {"iam:AttachGroupPolicy"}, + "iam:AttachRolePolicy": {"iam:AttachRolePolicy"}, + "iam:PutGroupPolicy": {"iam:PutGroupPolicy"}, + "iam:PutRolePolicy": {"iam:PutRolePolicy"}, + "iam:PutUserPolicy": {"iam:PutUserPolicy"}, + "iam:AddUserToGroup": {"iam:AddUserToGroup"}, + "iam:UpdateAssumeRolePolicy": {"iam:UpdateAssumeRolePolicy"}, + # IAM chained privilege escalation patterns + "CreateAccessKey+DeleteAccessKey": { + "iam:CreateAccessKey", + "iam:DeleteAccessKey", + }, + "AttachUserPolicy+CreateAccessKey": { + "iam:AttachUserPolicy", + "iam:CreateAccessKey", + }, + "PutUserPolicy+CreateAccessKey": { + "iam:PutUserPolicy", + "iam:CreateAccessKey", + }, + "AttachRolePolicy+UpdateAssumeRolePolicy": { + "iam:AttachRolePolicy", + "iam:UpdateAssumeRolePolicy", + }, + "CreatePolicyVersion+UpdateAssumeRolePolicy": { + "iam:CreatePolicyVersion", + "iam:UpdateAssumeRolePolicy", + }, + "PutRolePolicy+UpdateAssumeRolePolicy": { + "iam:PutRolePolicy", + "iam:UpdateAssumeRolePolicy", + }, + # STS-based privilege escalation patterns + "AssumeRole+AttachRolePolicy": {"sts:AssumeRole", "iam:AttachRolePolicy"}, + "AssumeRole+PutRolePolicy": {"sts:AssumeRole", "iam:PutRolePolicy"}, + "AssumeRole+UpdateAssumeRolePolicy": { + "sts:AssumeRole", + "iam:UpdateAssumeRolePolicy", + }, + "AssumeRole+CreatePolicyVersion": { + "sts:AssumeRole", + "iam:CreatePolicyVersion", + }, + # EC2-based privilege escalation patterns "PassRole+EC2": { "iam:PassRole", "ec2:RunInstances", }, + "PassRole+EC2SpotInstances": { + "iam:PassRole", + "ec2:RequestSpotInstances", + }, + # Prerequisite: Existing EC2 instance with admin role attached + "EC2ModifyInstanceAttribute": { + "ec2:ModifyInstanceAttribute", + "ec2:StopInstances", + "ec2:StartInstances", + }, + # Prerequisite: Existing launch template used by instances with admin role + "EC2ModifyLaunchTemplate": { + "ec2:CreateLaunchTemplateVersion", + "ec2:ModifyLaunchTemplate", + }, + # EC2 Instance Connect privilege escalation + # Prerequisite: Running EC2 with Instance Connect enabled and admin role + "EC2InstanceConnect+SendSSHPublicKey": { + "ec2-instance-connect:SendSSHPublicKey", + "ec2:DescribeInstances", + }, + # Lambda-based privilege escalation patterns "PassRole+CreateLambda+Invoke": { "iam:PassRole", "lambda:CreateFunction", @@ -45,68 +117,131 @@ privilege_escalation_policies_combination = { "dynamodb:CreateTable", "dynamodb:PutItem", }, - "PassRole+GlueEndpoint": { + "PassRole+CreateLambda+AddPermission": { + "iam:PassRole", + "lambda:CreateFunction", + "lambda:AddPermission", + }, + # Prerequisite: Existing Lambda function with admin execution role + "lambda:UpdateFunctionCode": {"lambda:UpdateFunctionCode"}, + # Prerequisite: Existing Lambda function with admin execution role + "lambda:UpdateFunctionConfiguration": {"lambda:UpdateFunctionConfiguration"}, + # Prerequisite: Existing Lambda function with admin execution role + "UpdateFunctionCode+InvokeFunction": { + "lambda:UpdateFunctionCode", + "lambda:InvokeFunction", + }, + # Prerequisite: Existing Lambda function with admin execution role + "UpdateFunctionCode+AddPermission": { + "lambda:UpdateFunctionCode", + "lambda:AddPermission", + }, + # Glue-based privilege escalation patterns + "PassRole+GlueCreateDevEndpoint": { "iam:PassRole", "glue:CreateDevEndpoint", - "glue:GetDevEndpoint", }, - "PassRole+GlueEndpoints": { + # Prerequisite: Existing Glue dev endpoint with admin role + "GlueUpdateDevEndpoint": {"glue:UpdateDevEndpoint"}, + "PassRole+GlueCreateJob+StartJobRun": { "iam:PassRole", - "glue:CreateDevEndpoint", - "glue:GetDevEndpoints", + "glue:CreateJob", + "glue:StartJobRun", }, - "PassRole+CloudFormation": { + "PassRole+GlueCreateJob+CreateTrigger": { + "iam:PassRole", + "glue:CreateJob", + "glue:CreateTrigger", + }, + # Prerequisite: Existing Glue job + "PassRole+GlueUpdateJob+StartJobRun": { + "iam:PassRole", + "glue:UpdateJob", + "glue:StartJobRun", + }, + # Prerequisite: Existing Glue job + "PassRole+GlueUpdateJob+CreateTrigger": { + "iam:PassRole", + "glue:UpdateJob", + "glue:CreateTrigger", + }, + # CloudFormation-based privilege escalation patterns + "PassRole+CloudFormationCreateStack": { "iam:PassRole", "cloudformation:CreateStack", - "cloudformation:DescribeStacks", }, + # Prerequisite: Existing CloudFormation stack with admin service role + "CloudFormationUpdateStack": {"cloudformation:UpdateStack"}, + "PassRole+CloudFormationCreateStackSet": { + "iam:PassRole", + "cloudformation:CreateStackSet", + "cloudformation:CreateStackInstances", + }, + # Prerequisite: Existing CloudFormation StackSet + "PassRole+CloudFormationUpdateStackSet": { + "iam:PassRole", + "cloudformation:UpdateStackSet", + }, + # Prerequisite: Existing CloudFormation stack with admin service role + "CloudFormationChangeSet": { + "cloudformation:CreateChangeSet", + "cloudformation:ExecuteChangeSet", + }, + # DataPipeline-based privilege escalation patterns "PassRole+DataPipeline": { "iam:PassRole", "datapipeline:CreatePipeline", "datapipeline:PutPipelineDefinition", "datapipeline:ActivatePipeline", }, - "GlueUpdateDevEndpoint": {"glue:UpdateDevEndpoint"}, - "lambda:UpdateFunctionCode": {"lambda:UpdateFunctionCode"}, - "lambda:UpdateFunctionConfiguration": {"lambda:UpdateFunctionConfiguration"}, + # CodeStar-based privilege escalation patterns "PassRole+CodeStar": { "iam:PassRole", "codestar:CreateProject", }, + # CodeBuild-based privilege escalation patterns + "PassRole+CodeBuildCreateProject+StartBuild": { + "iam:PassRole", + "codebuild:CreateProject", + "codebuild:StartBuild", + }, + "PassRole+CodeBuildCreateProject+StartBuildBatch": { + "iam:PassRole", + "codebuild:CreateProject", + "codebuild:StartBuildBatch", + }, + # Prerequisite: Existing CodeBuild project with admin service role + "CodeBuildStartBuild": {"codebuild:StartBuild"}, + # Prerequisite: Existing CodeBuild project with admin service role + "CodeBuildStartBuildBatch": {"codebuild:StartBuildBatch"}, + # AutoScaling-based privilege escalation patterns "PassRole+CreateAutoScaling": { "iam:PassRole", "autoscaling:CreateAutoScalingGroup", "autoscaling:CreateLaunchConfiguration", }, + # Prerequisite: Existing Auto Scaling group "PassRole+UpdateAutoScaling": { "iam:PassRole", "autoscaling:UpdateAutoScalingGroup", "autoscaling:CreateLaunchConfiguration", }, - "iam:CreateAccessKey": {"iam:CreateAccessKey"}, - "iam:CreateLoginProfile": {"iam:CreateLoginProfile"}, - "iam:UpdateLoginProfile": {"iam:UpdateLoginProfile"}, - "iam:AttachUserPolicy": {"iam:AttachUserPolicy"}, - "iam:AttachGroupPolicy": {"iam:AttachGroupPolicy"}, - "iam:AttachRolePolicy": {"iam:AttachRolePolicy"}, - "AssumeRole+AttachRolePolicy": {"sts:AssumeRole", "iam:AttachRolePolicy"}, - "iam:PutGroupPolicy": {"iam:PutGroupPolicy"}, - "iam:PutRolePolicy": {"iam:PutRolePolicy"}, - "AssumeRole+PutRolePolicy": {"sts:AssumeRole", "iam:PutRolePolicy"}, - "iam:PutUserPolicy": {"iam:PutUserPolicy"}, - "iam:AddUserToGroup": {"iam:AddUserToGroup"}, - "iam:UpdateAssumeRolePolicy": {"iam:UpdateAssumeRolePolicy"}, - "AssumeRole+UpdateAssumeRolePolicy": { - "sts:AssumeRole", - "iam:UpdateAssumeRolePolicy", - }, - # AgentCore privilege escalation patterns - "PassRole+AgentCoreCreateInterpreter+InvokeInterpreter": { - "iam:PassRole", - "bedrock-agentcore:CreateCodeInterpreter", - "bedrock-agentcore:InvokeCodeInterpreter", - }, # ECS-based privilege escalation patterns + "PassRole+ECS+RegisterTaskDef+CreateService": { + "iam:PassRole", + "ecs:RegisterTaskDefinition", + "ecs:CreateService", + }, + "PassRole+ECS+RegisterTaskDef+RunTask": { + "iam:PassRole", + "ecs:RegisterTaskDefinition", + "ecs:RunTask", + }, + "PassRole+ECS+RegisterTaskDef+StartTask": { + "iam:PassRole", + "ecs:RegisterTaskDefinition", + "ecs:StartTask", + }, # Reference: https://labs.reversec.com/posts/2025/08/another-ecs-privilege-escalation-path "PassRole+ECS+StartTask": { "iam:PassRole", @@ -114,10 +249,58 @@ privilege_escalation_policies_combination = { "ecs:RegisterContainerInstance", "ecs:DeregisterContainerInstance", }, + # Prerequisite: Existing ECS cluster and task definition with admin role "PassRole+ECS+RunTask": { "iam:PassRole", "ecs:RunTask", }, + # SageMaker-based privilege escalation patterns + "PassRole+SageMakerCreateNotebookInstance": { + "iam:PassRole", + "sagemaker:CreateNotebookInstance", + }, + "PassRole+SageMakerCreateTrainingJob": { + "iam:PassRole", + "sagemaker:CreateTrainingJob", + }, + "PassRole+SageMakerCreateProcessingJob": { + "iam:PassRole", + "sagemaker:CreateProcessingJob", + }, + # Prerequisite: Existing SageMaker notebook instance with admin role + "SageMakerCreatePresignedNotebookInstanceUrl": { + "sagemaker:CreatePresignedNotebookInstanceUrl", + }, + # Prerequisite: Existing SageMaker notebook instance with admin role + "SageMakerNotebookLifecycleConfig": { + "sagemaker:CreateNotebookInstanceLifecycleConfig", + "sagemaker:StopNotebookInstance", + "sagemaker:UpdateNotebookInstance", + "sagemaker:StartNotebookInstance", + }, + # SSM-based privilege escalation patterns + # Prerequisite: Running EC2 with SSM agent and admin instance profile + "SSMStartSession": {"ssm:StartSession"}, + # Prerequisite: Running EC2 with SSM agent and admin instance profile + "SSMSendCommand": {"ssm:SendCommand"}, + # AppRunner-based privilege escalation patterns + "PassRole+AppRunnerCreateService": { + "iam:PassRole", + "apprunner:CreateService", + }, + # Prerequisite: Existing App Runner service with admin role + "AppRunnerUpdateService": {"apprunner:UpdateService"}, + # Bedrock AgentCore privilege escalation patterns + "PassRole+AgentCoreCreateInterpreter+InvokeInterpreter": { + "iam:PassRole", + "bedrock-agentcore:CreateCodeInterpreter", + "bedrock-agentcore:InvokeCodeInterpreter", + }, + # Prerequisite: Existing Bedrock code interpreter with admin role + "AgentCoreSessionInvoke": { + "bedrock-agentcore:StartCodeInterpreterSession", + "bedrock-agentcore:InvokeCodeInterpreter", + }, # TO-DO: We have to handle AssumeRole just if the resource is * and without conditions # "sts:AssumeRole": {"sts:AssumeRole"}, } diff --git a/prowler/providers/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index db434c0675..506b433690 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -1,4 +1,5 @@ import asyncio +import logging import os import re from argparse import ArgumentTypeError @@ -217,6 +218,9 @@ class AzureProvider(Provider): """ logger.info("Setting Azure provider ...") + # Mute HPACK library logs to prevent token leakage in debug mode + logging.getLogger("hpack").setLevel(logging.CRITICAL) + logger.info("Checking if any credentials mode is set ...") # Validate the authentication arguments diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment.metadata.json index 98f91e2a31..4c20e474e6 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_alert_create_policy_assignment", - "CheckTitle": "Ensure that Activity Log Alert exists for Create Policy Assignment", + "CheckTitle": "Subscription has an Azure Monitor activity log alert for policy assignment creation", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Monitor", + "Severity": "medium", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the Create Policy Assignment event.", - "Risk": "Monitoring for create policy assignment events gives insight into changes done in 'Azure policy - assignments' and can reduce the time it takes to detect unsolicited changes.", - "RelatedUrl": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement", + "Description": "**Azure Monitor Activity Log alert** configurations are assessed for an **activity log alert** on `Microsoft.Authorization/policyAssignments/write`, indicating monitoring of newly created **Azure Policy assignments**", + "Risk": "Absent alerts on new policy assignments, unauthorized or accidental changes can silently weaken governance. Adversaries could assign permissive policies or replace deny rules, enabling misconfigurations, privilege expansion, and data exposure-degrading **integrity** and threatening **confidentiality** and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/dotnet/api/azure.resourcemanager.monitor.activitylogalertresource?view=azure-dotnet", + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/create-alert-for-create-policy-assignment-events.html" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Authorization/policyAssignments/write and level= --scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/create-alert-for-create-policy-assignment-events.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --name '' --resource-group '' --location global --scopes '/subscriptions/' --condition \"category=Administrative and operationName=Microsoft.Authorization/policyAssignments/write\" --enabled true", + "NativeIaC": "```bicep\n// Azure Monitor Activity Log Alert for Policy Assignment creation\nresource activityLogAlert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'global'\n properties: {\n enabled: true\n scopes: [ '/subscriptions/' ]\n condition: {\n allOf: [\n {\n field: 'category'\n equals: 'Administrative' // Critical: filter Activity Log category to Administrative\n }\n {\n field: 'operationName'\n equals: 'Microsoft.Authorization/policyAssignments/write' // Critical: alert on Policy Assignment creation\n }\n ]\n }\n }\n}\n```", + "Other": "1. In the Azure Portal, go to Monitor > Alerts > Alert rules\n2. Click + Create > Alert rule\n3. Scope: Select the target Subscription and click Apply\n4. Condition: Choose Activity log, then set Category = Administrative and Operation name = Microsoft.Authorization/policyAssignments/write; click Apply\n5. Actions: Skip or select an existing Action group (optional)\n6. Details: Enter a Name and ensure Enable alert rule upon creation is checked\n7. Click Review + create, then Create", + "Terraform": "```hcl\n# Azure Monitor Activity Log Alert for Policy Assignment creation\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"global\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"Administrative\" # Critical: Activity Log category\n operation_name = \"Microsoft.Authorization/policyAssignments/write\" # Critical: Policy Assignment creation\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Policy assignment (policyAssignments). 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Create policy assignment (Microsoft.Authorization/policyAssignments). 12. Select the Actions tab. 13. To use an existing action group, click elect action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log" + "Text": "Implement an **activity log alert** for `Microsoft.Authorization/policyAssignments/write` and route to an action group for timely response.\n\nApply across all subscriptions, restrict assignment rights (**least privilege**), require change approval, and integrate notifications with your SIEM for **defense in depth**.", + "Url": "https://hub.prowler.com/check/monitor_alert_create_policy_assignment" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg.metadata.json index 16cc7a23a0..ddef49c8c3 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_alert_create_update_nsg", - "CheckTitle": "Ensure that Activity Log Alert exists for Create or Update Network Security Group", + "CheckTitle": "Subscription has an Activity Log alert for Network Security Group create or update operations", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Monitor", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an Activity Log Alert for the Create or Update Network Security Group event.", - "Risk": "Monitoring for Create or Update Network Security Group events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", - "RelatedUrl": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log", + "Description": "**Azure Monitor Activity Log alert** monitors **Network Security Group** changes via the `Microsoft.Network/networkSecurityGroups/write` operation to capture create/update events across the subscription", + "Risk": "Lack of alerting on NSG changes allows **unauthorized network policy modifications** to go unnoticed. Adversaries or mistakes could open ports, reduce segmentation, and enable **lateral movement**, impacting data **confidentiality** and service **availability** through exposure or disruption of critical traffic", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-schema", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/create-update-network-security-group-rule-alert-in-use.html" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Network/networkSecurityGroups/write and level=verbose --scope '/subscriptions/' --name '' --subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/create-update-network-security-group-rule-alert-in-use.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --resource-group '' --name '' --scopes '/subscriptions/' --condition \"category=Administrative and operationName=Microsoft.Network/networkSecurityGroups/write\" --location global", + "NativeIaC": "```bicep\n// Activity Log alert for NSG create/update\nresource alert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'Global'\n properties: {\n scopes: [ subscription().id ]\n condition: {\n allOf: [\n { field: 'category', equals: 'Administrative' }\n { field: 'operationName', equals: 'Microsoft.Network/networkSecurityGroups/write' } // Critical: triggers on NSG create/update\n ]\n }\n enabled: true // Ensures the alert is active\n }\n}\n```", + "Other": "1. In the Azure portal, go to Monitor > Alerts > Alert rules > Create\n2. Scope: Select your subscription and click Apply\n3. Condition: Choose Activity log, set Category to Administrative, set Operation name to Microsoft.Network/networkSecurityGroups/write, then Done\n4. Actions: Skip (optional)\n5. Details: Name the rule and set Region to Global, ensure Enable upon creation is checked\n6. Review + create > Create", + "Terraform": "```hcl\n# Activity Log alert for NSG create/update\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"Administrative\"\n operation_name = \"Microsoft.Network/networkSecurityGroups/write\" # Critical: triggers on NSG create/update\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Network security groups. 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Create or Update Network Security Group (Microsoft.Network/networkSecurityGroups). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + "Text": "Implement a subscription-wide **Activity Log alert** for NSG change operations and route notifications to an **action group** for rapid triage.\n\nApply **least privilege** for change tooling, enforce **change management**, and add complementary alerts for `Microsoft.Network/networkSecurityGroups/securityRules/write` and `.../delete`. *Integrate with SIEM for correlation*", + "Url": "https://hub.prowler.com/check/monitor_alert_create_update_nsg" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule.metadata.json index d26f751076..71d3370f20 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule.metadata.json @@ -1,30 +1,39 @@ { "Provider": "azure", "CheckID": "monitor_alert_create_update_public_ip_address_rule", - "CheckTitle": "Ensure that Activity Log Alert exists for Create or Update Public IP Address rule", + "CheckTitle": "Subscription has an Activity Log Alert for Public IP address create or update operations", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Monitor", + "Severity": "medium", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the Create or Update Public IP Addresses rule.", - "Risk": "Monitoring for Create or Update Public IP Address events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", - "RelatedUrl": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log", + "Description": "**Azure Monitor activity log alert** for **Public IP addresses** tracks `Microsoft.Network/publicIPAddresses/write` events at the subscription level, covering any creation or update of public IP resources.", + "Risk": "Without this alert, unauthorized or mistaken public IP changes can go unnoticed, exposing workloads to the Internet.\n- Confidentiality: unexpected ingress paths\n- Integrity: shadow endpoints for control\n- Availability: larger DDoS surface and outages", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/create-or-update-public-ip-alert.html", + "https://support.icompaas.com/support/solutions/articles/62000229918-ensure-that-activity-log-alert-exists-for-create-or-update-public-ip-address-rule", + "https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/monitor-public-ip" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Network/publicIPAddresses/write and level=--scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/create-or-update-public-ip-alert.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --resource-group --name --scopes /subscriptions/ --condition \"category=Administrative and operationName=Microsoft.Network/publicIPAddresses/write\" --location global", + "NativeIaC": "```bicep\n// Activity Log Alert for Public IP create/update\nresource alert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'global'\n properties: {\n enabled: true\n scopes: ['/subscriptions/']\n condition: {\n allOf: [\n { field: 'category', equals: 'Administrative' }\n { field: 'operationName', equals: 'Microsoft.Network/publicIPAddresses/write' } // Critical: alerts on Public IP create/update\n ]\n }\n }\n}\n```", + "Other": "1. In Azure Portal, go to Monitor > Alerts > Alert rules > Create\n2. Scope: Select your subscription and click Done\n3. Condition: Choose Activity log, then select the signal \"Create or Update Public Ip Address (publicIPAddresses)\"\n4. Details: Enter an alert rule name; Region: Global; Ensure Enable alert rule upon creation is checked\n5. Click Review + create, then Create", + "Terraform": "```hcl\n# Activity Log Alert for Public IP create/update\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"Administrative\"\n operation_name = \"Microsoft.Network/publicIPAddresses/write\" # Critical: alerts on Public IP create/update\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Public IP addresses. 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Create or Update Public Ip Address (Microsoft.Network/publicIPAddresses). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + "Text": "Create a subscription-wide **activity log alert** on `Microsoft.Network/publicIPAddresses/write` and route it to an **action group**.\n\nEnforce **least privilege** for IP management, apply **change control**, and use **defense in depth** (private endpoints, bastions, VPN) to minimize public exposure and speed response.", + "Url": "https://hub.prowler.com/check/monitor_alert_create_update_public_ip_address_rule" } }, - "Categories": [], + "Categories": [ + "logging", + "forensics-ready" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution.metadata.json index 6fbdea162f..a19896c16b 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_alert_create_update_security_solution", - "CheckTitle": "Ensure that Activity Log Alert exists for Create or Update Security Solution", + "CheckTitle": "Subscription has Activity Log alert for Security Solution create or update", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Monitor", + "Severity": "medium", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the Create or Update Security Solution event.", - "Risk": "Monitoring for Create or Update Security Solution events gives insight into changes to the active security solutions and may reduce the time it takes to detect suspicious activity.", - "RelatedUrl": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log", + "Description": "**Azure Monitor activity log alert** is configured to capture **Security Solutions** create/update operations (`Microsoft.Security/securitySolutions/write`) at subscription scope.", + "Risk": "Without this alert, **unauthorized or mistaken changes** to security tooling can go undetected. Attackers could disable defenses, alter integrations, or weaken policies, eroding the **integrity** of controls, creating blind spots that threaten **confidentiality**, and delaying incident response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement", + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/create-or-update-security-solution-alert.html#trendmicro" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Security/securitySolutions/write and level=--scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/create-or-update-security-solution-alert.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --name \"\" --resource-group \"\" --scopes \"/subscriptions/\" --condition \"category=Administrative and operationName=Microsoft.Security/securitySolutions/write\" --location Global", + "NativeIaC": "```bicep\n// Activity Log Alert for Security Solution create/update\nresource activityLogAlert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'Global'\n properties: {\n scopes: [ '/subscriptions/' ]\n condition: {\n allOf: [\n {\n field: 'category'\n equals: 'Administrative'\n }\n {\n field: 'operationName' // Critical: match Security Solution create/update\n equals: 'Microsoft.Security/securitySolutions/write' // Triggers on this operation\n }\n ]\n }\n enabled: true\n }\n}\n```", + "Other": "1. In the Azure portal, go to Monitor > Alerts > + Create > Alert rule\n2. Scope: Select your Subscription and click Apply\n3. Condition: Choose Activity log, set Signal name to Administrative, then add a filter Operation name = Microsoft.Security/securitySolutions/write\n4. Actions: Skip (no action group required)\n5. Details: Enter a Name, set Region to Global, ensure Enable alert rule upon creation is checked\n6. Review + create > Create", + "Terraform": "```hcl\n# Activity Log Alert for Security Solution create/update\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"Administrative\"\n operation_name = \"Microsoft.Security/securitySolutions/write\" # Critical: fires on Security Solution create/update\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Security Solutions (securitySolutions). 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Create or Update Security Solutions (Microsoft.Security/securitySolutions). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + "Text": "Configure an **activity log alert** for `Microsoft.Security/securitySolutions/write` and route it to action groups for prompt notification/automation.\n\nApply **least privilege**, require **change control**, and forward alerts to a central SIEM to strengthen **defense in depth**.", + "Url": "https://hub.prowler.com/check/monitor_alert_create_update_security_solution" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr.metadata.json index 11f0d0b459..6458ac7e27 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_alert_create_update_sqlserver_fr", - "CheckTitle": "Ensure that Activity Log Alert exists for Create or Update SQL Server Firewall Rule", + "CheckTitle": "Subscription has an Activity Log alert for SQL Server firewall rule create or update events", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Monitor", + "Severity": "medium", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the Create or Update SQL Server Firewall Rule event.", - "Risk": "Monitoring for Create or Update SQL Server Firewall Rule events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", - "RelatedUrl": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log", + "Description": "**Azure Monitor activity log alerts** are configured for **Azure SQL Server firewall rule changes**, targeting the `Microsoft.Sql/servers/firewallRules/write` operation.\n\nThis evaluates whether notifications or automated actions are set when firewall rules are created or updated.", + "Risk": "Without alerting on firewall rule changes, unauthorized or accidental openings can remain unnoticed, exposing databases to untrusted networks.\n\nThis harms **confidentiality** (data exfiltration via widened IP ranges) and **integrity** (unauthorized queries), while increasing attacker dwell time.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement", + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/create-or-update-or-delete-sql-server-firewall-rule-alert.html#trendmicro" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Sql/servers/firewallRules/write and level=--scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/create-or-update-or-delete-sql-server-firewall-rule-alert.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --name --resource-group --scopes /subscriptions/ --condition \"category=Administrative and operationName=Microsoft.Sql/servers/firewallRules/write\" --location global", + "NativeIaC": "```bicep\n// Activity Log alert for SQL Server firewall rule create/update\nresource example_activity_log_alert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'Global'\n properties: {\n enabled: true\n scopes: [ '/subscriptions/' ]\n condition: {\n allOf: [\n {\n field: 'category'\n equals: 'Administrative'\n }\n {\n field: 'operationName'\n equals: 'Microsoft.Sql/servers/firewallRules/write' // Critical: alert on SQL Server firewall rule create/update\n }\n ]\n }\n }\n}\n```", + "Other": "1. In the Azure portal, go to Monitor > Alerts > + Create > Alert rule\n2. Scope: Select the subscription and click Done\n3. Condition: Choose Signal type \"Activity log\", then set\n - Category: Administrative\n - Operation name: Microsoft.Sql/servers/firewallRules/write\n Click Done\n4. Actions: Skip (no action group required)\n5. Details: Enter an Alert rule name and ensure Enable alert rule upon creation is checked\n6. Review + create > Create", + "Terraform": "```hcl\n# Activity Log alert for SQL Server firewall rule create/update\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"Administrative\"\n operation_name = \"Microsoft.Sql/servers/firewallRules/write\" # Critical: alert on SQL Server firewall rule create/update\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Server Firewall Rule (servers/firewallRules). 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Create/Update server firewall rule (Microsoft.Sql/servers/firewallRules). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + "Text": "Enable an activity log alert for `Microsoft.Sql/servers/firewallRules/write` and route it to responsive action groups.\n\nApply **least privilege** for firewall management, enforce change approvals, and use **defense in depth**: prefer **private endpoints** and avoid broad public network access.", + "Url": "https://hub.prowler.com/check/monitor_alert_create_update_sqlserver_fr" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg.metadata.json index 955d4682be..bcdd0ddac8 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "monitor_alert_delete_nsg", - "CheckTitle": "Ensure that Activity Log Alert exists for Delete Network Security Group", + "CheckTitle": "Subscription has an Activity Log alert for Network Security Group delete operations", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Monitor", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the Delete Network Security Group event.", - "Risk": "Monitoring for 'Delete Network Security Group' events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", - "RelatedUrl": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log", + "Description": "**Azure Monitor activity log alerts** include the NSG deletion signal (`Microsoft.Network/networkSecurityGroups/delete` or `Microsoft.ClassicNetwork/networkSecurityGroups/delete`). The finding indicates whether a subscription has an alert rule configured to trigger when a Network Security Group is deleted.", + "Risk": "Without alerting on **NSG deletions**, network segmentation can be removed unnoticed, exposing services to broad ingress/egress. Malicious actors or automation may delete NSGs to enable **lateral movement** and **data exfiltration**. Missing alerts delay response, impacting confidentiality and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/delete-network-security-group-rule-alert-in-use.html#trendmicro" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Network/networkSecurityGroups/delete and level=--scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/delete-network-security-group-rule-alert-in-use.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --name \"\" --resource-group \"\" --scopes \"/subscriptions/\" --condition \"category=Administrative and operationName=Microsoft.Network/networkSecurityGroups/delete\" --location global", + "NativeIaC": "```bicep\n// Activity Log alert for NSG delete\nresource activityAlert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'Global'\n properties: {\n scopes: ['/subscriptions/']\n enabled: true\n condition: {\n allOf: [\n { field: 'category', equals: 'Administrative' } // Critical: filter Activity Log to Administrative category\n { field: 'operationName', equals: 'Microsoft.Network/networkSecurityGroups/delete' } // Critical: triggers on NSG delete\n ]\n }\n }\n}\n```", + "Other": "1. In Azure Portal, go to Monitor > Alerts > Alert rules\n2. Click + Create > Alert rule\n3. Scope: Select the target subscription and click Apply\n4. Condition: Choose Activity log, select the signal \"Delete Network Security Group\" (operation Microsoft.Network/networkSecurityGroups/delete); ensure Category is Administrative\n5. Details: Enter a name; leave other settings as default\n6. Click Review + create, then Create", + "Terraform": "```hcl\n# Activity Log alert for NSG delete\nresource \"azurerm_monitor_activity_log_alert\" \"example\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"Administrative\" # Critical: Activity Log category filter\n operation_name = \"Microsoft.Network/networkSecurityGroups/delete\" # Critical: alert on NSG delete\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Network security groups. 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Delete Network Security Group (Microsoft.Network/networkSecurityGroups). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + "Text": "Configure a subscription-wide **activity log alert** for the NSG delete operation (`Microsoft.Network/networkSecurityGroups/delete`; include Classic if applicable) and route notifications via **action groups**. Enforce **least privilege** for NSG changes, require **change control**, and integrate with your **SIEM** for correlation.", + "Url": "https://hub.prowler.com/check/monitor_alert_delete_nsg" } }, - "Categories": [], + "Categories": [ + "logging", + "forensics-ready" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment.metadata.json index 113b2f85b5..12c674de4a 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_alert_delete_policy_assignment", - "CheckTitle": "Ensure that Activity Log Alert exists for Delete Policy Assignment", + "CheckTitle": "Subscription has an Activity Log alert for policy assignment deletion", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Monitor", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the Delete Policy Assignment event.", - "Risk": "Monitoring for delete policy assignment events gives insight into changes done in 'azure policy - assignments' and can reduce the time it takes to detect unsolicited changes.", - "RelatedUrl": "https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate", + "Description": "**Azure Monitor Activity log alerts** for policy assignment deletions using the `Microsoft.Authorization/policyAssignments/delete` operation at subscription scope", + "Risk": "Without this alert, **policy assignment deletions** can go unnoticed, eroding configuration **integrity** and enabling governance drift. Malicious or accidental changes may remove guardrails, increasing exposure and threatening **confidentiality** of protected resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://learn.microsoft.com/en-in/rest/api/monitor/activity-log-alerts/create-or-update?view=rest-monitor-2020-10-01&tabs=HTTP", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/delete-policy-assignment-alert-in-use.html#trendmicro" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Authorization/policyAssignments/delete and level= --scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/delete-policy-assignment-alert-in-use.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --resource-group --name --scopes \"/subscriptions/\" --condition \"operationName=Microsoft.Authorization/policyAssignments/delete\" --location global", + "NativeIaC": "```bicep\n// Activity Log alert for Policy Assignment deletion\nresource alert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'Global'\n properties: {\n scopes: [\n '/subscriptions/'\n ]\n condition: {\n allOf: [\n {\n field: 'operationName'\n equals: 'Microsoft.Authorization/policyAssignments/delete' // CRITICAL: alerts on policy assignment deletion\n }\n ]\n }\n actions: {\n actionGroups: [] // Required property; empty keeps rule minimal\n }\n }\n}\n```", + "Other": "1. In Azure Portal, go to Monitor > Alerts > Alert rules\n2. Click + Create > Alert rule\n3. Scope: Select your subscription and click Apply\n4. Condition: Choose Activity log, then set Operation name equals \"Microsoft.Authorization/policyAssignments/delete\"\n5. Actions: Skip (optional)\n6. Details: Enter a name and set Enable alert rule upon creation\n7. Click Create", + "Terraform": "```hcl\n# Activity Log alert for Policy Assignment deletion\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n operation_name = \"Microsoft.Authorization/policyAssignments/delete\" # CRITICAL: alerts on policy assignment deletion\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Policy assignment (policyAssignments). 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Delete policy assignment (Microsoft.Authorization/policyAssignments). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log" + "Text": "- Configure an activity log alert for `Microsoft.Authorization/policyAssignments/delete` and route to an action group.\n- Enforce **least privilege** and **separation of duties** for policy changes and require approvals.\n- Integrate alerts with your SIEM and define playbooks for rapid response.", + "Url": "https://hub.prowler.com/check/monitor_alert_delete_policy_assignment" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule.metadata.json index 704eb87afa..30187969bf 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_alert_delete_public_ip_address_rule", - "CheckTitle": "Ensure that Activity Log Alert exists for Delete Public IP Address rule", + "CheckTitle": "Azure subscription has an Activity Log alert for public IP address deletion", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Monitor", + "Severity": "medium", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the Delete Public IP Address rule.", - "Risk": "Monitoring for Delete Public IP Address events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", - "RelatedUrl": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log", + "Description": "**Azure Monitor activity log alert** exists for the **Delete Public IP Address** operation (`Microsoft.Network/publicIPAddresses/delete`), capturing subscription-wide events when Public IP resources are removed.", + "Risk": "Unmonitored deletion of Public IPs can abruptly sever ingress/egress, break DNS and allowlists, and take services offline (**availability**). Attackers or misconfigurations can delete IPs to cause **DoS** or evade controls, and delayed visibility hinders **incident response** and **forensics**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/delete-public-ip-alert.html#trendmicro", + "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement", + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Network/publicIPAddresses/delete and level=--scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/delete-public-ip-alert.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --name --resource-group --location global --scopes /subscriptions/ --condition category=Administrative and operationName=Microsoft.Network/publicIPAddresses/delete", + "NativeIaC": "```bicep\n// Activity Log alert for Public IP deletion\nresource alert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'Global'\n properties: {\n enabled: true\n scopes: [\n '/subscriptions/' // Scope the alert to the subscription\n ]\n condition: {\n allOf: [\n { field: 'category', equals: 'Administrative' }\n { field: 'operationName', equals: 'Microsoft.Network/publicIPAddresses/delete' } // Critical: triggers when a Public IP is deleted\n ]\n }\n }\n}\n```", + "Other": "1. In the Azure portal, go to Monitor > Alerts > + Create > Alert rule\n2. Scope: Select your subscription and click Apply\n3. Condition: Choose Activity log, then set Category = Administrative and Operation name = Microsoft.Network/publicIPAddresses/delete; click Apply\n4. Actions: Skip (no action group required to pass)\n5. Details: Enter an alert name, set Region to Global, ensure Enable alert rule upon creation is checked\n6. Review + create > Create", + "Terraform": "```hcl\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"Administrative\"\n operation_name = \"Microsoft.Network/publicIPAddresses/delete\" # Critical: alert when a Public IP is deleted\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Public IP addresses. 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Delete Public Ip Address (Microsoft.Network/publicIPAddresses). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + "Text": "Implement an activity log alert for `Microsoft.Network/publicIPAddresses/delete` and route it to an action group for rapid response.\n- Apply **least privilege** and change approval for IP deletions\n- Use **resource locks** on critical IPs\n- Centralize alerts in your SIEM and define runbooks for containment", + "Url": "https://hub.prowler.com/check/monitor_alert_delete_public_ip_address_rule" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution.metadata.json index 97eb0e2df9..34cbc9522a 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "monitor_alert_delete_security_solution", - "CheckTitle": "Ensure that Activity Log Alert exists for Delete Security Solution", + "CheckTitle": "Subscription has an Azure Monitor Activity Log alert for Microsoft.Security/securitySolutions delete operations", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Monitor", + "Severity": "medium", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the Delete Security Solution event.", - "Risk": "Monitoring for Delete Security Solution events gives insight into changes to the active security solutions and may reduce the time it takes to detect suspicious activity.", - "RelatedUrl": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log", + "Description": "**Azure activity log alerts** monitor deletions of **Security Solutions** by targeting the operation `Microsoft.Security/securitySolutions/delete` at subscription scope.\n\nIdentifies whether notifications are configured for security solution removal events.", + "Risk": "Without this alert, **unauthorized or accidental deletions** of security tooling may go **unnoticed**, reducing the **availability** of protections and the **integrity** of monitoring. Adversaries can evade defenses, prolong dwell time, and enable **data exfiltration** under reduced visibility.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/delete-security-solution-alert.html", + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://learn.microsoft.com/en-us/cli/azure/monitor/activity-log/alert?view=azure-cli-latest", + "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Security/securitySolutions/delete and level=--scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/delete-security-solution-alert.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create -g -n --condition operationName=Microsoft.Security/securitySolutions/delete --scope /subscriptions/", + "NativeIaC": "```bicep\n// Activity Log Alert for Security Solution delete\nresource alert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'global'\n properties: {\n enabled: true\n scopes: [ subscription().id ]\n condition: {\n allOf: [\n {\n field: 'operationName'\n equals: 'Microsoft.Security/securitySolutions/delete' // Critical: alerts on Security Solution delete\n }\n ]\n }\n }\n}\n```", + "Other": "1. In Azure portal, go to Monitor > Alerts > + Create > Alert rule\n2. Scope: Select your subscription and click Apply\n3. Condition: Click Add condition, search and select \"Delete Security Solutions (Microsoft.Security/securitySolutions)\", then Add\n4. Ensure no filters for Level or Status are set\n5. Details: Enter an Alert rule name and choose a resource group\n6. Create: Review + create, then Create", + "Terraform": "```hcl\n# Activity Log Alert for Security Solution delete\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n operation_name = \"Microsoft.Security/securitySolutions/delete\" # Critical: alerts on delete operation\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Security Solutions (securitySolutions). 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Delete Security Solutions (Microsoft.Security/securitySolutions). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.curitySolutions). 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Create or Update Security Solutions (Microsoft.Security/securitySolutions). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + "Text": "Configure a **dedicated activity log alert** for `Microsoft.Security/securitySolutions/delete` and route it to resilient **action groups** (email, chat, ticketing, SIEM). Apply **least privilege** and **resource locks** to deter tampering. Test alerting routinely and integrate it into **defense-in-depth** monitoring.", + "Url": "https://hub.prowler.com/check/monitor_alert_delete_security_solution" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr.metadata.json index b810934718..9a6debf62e 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_alert_delete_sqlserver_fr", - "CheckTitle": "Ensure that Activity Log Alert exists for Delete SQL Server Firewall Rule", + "CheckTitle": "Subscription has an Activity Log Alert for SQL Server firewall rule deletions", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Monitor", + "Severity": "medium", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Create an activity log alert for the 'Delete SQL Server Firewall Rule.'", - "Risk": "Monitoring for Delete SQL Server Firewall Rule events gives insight into SQL network access changes and may reduce the time it takes to detect suspicious activity.", - "RelatedUrl": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log", + "Description": "**Azure Monitor Activity log alerts** watch the admin operation `Microsoft.Sql/servers/firewallRules/delete`, indicating when an **Azure SQL firewall rule** is removed across a subscription.", + "Risk": "Without alerting on firewall rule deletions, unexpected changes to SQL network allowlists can go unnoticed, causing **availability** loss for apps and masking **unauthorized tampering**. A compromised admin could remove rules to disrupt service, erode control **integrity**, and delay response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement", + "https://learn.microsoft.com/en-in/azure/azure-monitor/alerts/alerts-create-activity-log-alert-rule?tabs=activity-log", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/create-or-update-or-delete-sql-server-firewall-rule-alert.html#trendmicro" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --resource-group '' --condition category=Administrative and operationName=Microsoft.Sql/servers/firewallRules/delete and level=--scope '/subscriptions/' --name '' -- subscription --action-group --location global", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/ActivityLog/create-or-update-or-delete-sql-server-firewall-rule-alert.html#trendmicro", - "Terraform": "" + "CLI": "az monitor activity-log alert create --resource-group --name --scopes /subscriptions/ --condition \"category=Administrative and operationName=Microsoft.Sql/servers/firewallRules/delete\" --location global", + "NativeIaC": "```bicep\n// Activity Log Alert for SQL Server firewall rule deletions\nresource activityLogAlert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'Global'\n properties: {\n scopes: [\n '/subscriptions/'\n ]\n enabled: true\n condition: {\n allOf: [\n {\n field: 'category' // Critical: filter Activity Log category\n equals: 'Administrative' // Ensures Administrative events are matched\n }\n {\n field: 'operationName' // Critical: target deletion of SQL Server firewall rules\n equals: 'Microsoft.Sql/servers/firewallRules/delete' // This makes the check PASS\n }\n ]\n }\n }\n}\n```", + "Other": "1. In the Azure Portal, go to Monitor > Alerts > + Create > Alert rule\n2. Scope: Select the target Subscription and click Done\n3. Condition: Click Add condition, choose Signal type = Activity log, search for and select the operation with type \"Microsoft.Sql/servers/firewallRules/delete\" (display name like \"Delete Server Firewall Rule\"), then Click Apply\n4. Actions: Skip (optional)\n5. Details: Enter an Alert rule name and ensure Enable upon creation is selected\n6. Click Create", + "Terraform": "```hcl\n# Activity Log Alert for SQL Server firewall rule deletions\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"Administrative\" # Critical: filter Activity Log category\n operation_name = \"Microsoft.Sql/servers/firewallRules/delete\" # Critical: match deletion of SQL Server firewall rules\n }\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to the Monitor blade. 2. Select Alerts. 3. Select Create. 4. Select Alert rule. 5. Under Filter by subscription, choose a subscription. 6. Under Filter by resource type, select Server Firewall Rule (servers/firewallRules). 7. Under Filter by location, select All. 8. From the results, select the subscription. 9. Select Done. 10. Select the Condition tab. 11. Under Signal name, click Delete server firewall rule (Microsoft.Sql/servers/firewallRules). 12. Select the Actions tab. 13. To use an existing action group, click Select action groups. To create a new action group, click Create action group. Fill out the appropriate details for the selection. 14. Select the Details tab. 15. Select a Resource group, provide an Alert rule name and an optional Alert rule description. 16. Click Review + create. 17. Click Create.", - "Url": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement" + "Text": "Create an **activity log alert** for `Microsoft.Sql/servers/firewallRules/delete` and route it via an **action group** for rapid triage.\n\nEnforce **least privilege** and **separation of duties** on SQL admins, add alerts for related create/update operations, integrate with **SIEM**, and require *change approval* to strengthen defense in depth.", + "Url": "https://hub.prowler.com/check/monitor_alert_delete_sqlserver_fr" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, no monitoring alerts are created." diff --git a/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.metadata.json index 87b5afe627..9f96bcc322 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.metadata.json @@ -1,30 +1,39 @@ { "Provider": "azure", "CheckID": "monitor_alert_service_health_exists", - "CheckTitle": "Ensure that an Activity Log Alert exists for Service Health", + "CheckTitle": "Azure subscription has an enabled Activity Log alert for Service Health incidents", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Monitor", + "Severity": "medium", + "ResourceType": "microsoft.insights/activitylogalerts", "ResourceGroup": "monitoring", - "Description": "Ensure that an Azure activity log alert is configured to trigger when Service Health events occur within your Microsoft Azure cloud account. The alert should activate when new events match the specified conditions in the alert rule configuration.", - "Risk": "Lack of monitoring for Service Health events may result in missing critical service issues, planned maintenance, security advisories, or other changes that could impact Azure services and regions in use.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/service-health/overview", + "Description": "**Azure Monitor Activity Log alert** is configured for **Service Health** notifications where `category` is `ServiceHealth` and `properties.incidentType` is `Incident`, with the rule enabled.", + "Risk": "Without alerts for **Service Health incidents**, teams may miss Azure outages or degradations, harming **availability** and delaying failover. Unseen incidents can cause cascading errors, timeouts, deployment failures, and SLA breaches across dependent workloads.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/service-health/service-health-notifications-properties", + "https://learn.microsoft.com/en-us/azure/service-health/alerts-activity-log-service-notifications-portal", + "https://learn.microsoft.com/en-us/azure/service-health/overview", + "https://learn.microsoft.com/en-us/azure/azure-monitor/platform/activity-log-schema", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/service-health-alert.html" + ], "Remediation": { "Code": { - "CLI": "az monitor activity-log alert create --subscription --resource-group --name --condition category=ServiceHealth and properties.incidentType=Incident --scope /subscriptions/ --action-group ", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/service-health-alert.html", - "Terraform": "" + "CLI": "az monitor activity-log alert create --resource-group --name --scopes /subscriptions/ --condition \"category=ServiceHealth and properties.incidentType=Incident\"", + "NativeIaC": "```bicep\n// Activity Log Alert for Service Health Incidents\nresource alert 'Microsoft.Insights/activityLogAlerts@2020-10-01' = {\n name: ''\n location: 'Global'\n properties: {\n enabled: true\n scopes: [ subscription().id ]\n condition: {\n allOf: [\n { field: 'category', equals: 'ServiceHealth' } // Critical: match Service Health category\n { field: 'properties.incidentType', equals: 'Incident' } // Critical: alert only on Incident events\n ]\n }\n }\n}\n```", + "Other": "1. In the Azure portal, go to Service Health > Health alerts > Create service health alert\n2. Scope: select your Subscription and choose the Resource group to save the alert\n3. Event types: select only Service issues (Incidents)\n4. Leave other filters as default, ensure Enable rule is On, then click Create", + "Terraform": "```hcl\n# Activity Log Alert for Service Health Incidents\nresource \"azurerm_monitor_activity_log_alert\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n scopes = [\"/subscriptions/\"]\n\n criteria {\n category = \"ServiceHealth\" # Critical: Service Health category\n service_health {\n events = [\"Incident\"] # Critical: alert only on Incident type\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Create an activity log alert for Service Health events and configure an action group to notify appropriate personnel.", - "Url": "https://learn.microsoft.com/en-us/azure/service-health/alerts-activity-log-service-notifications-portal" + "Text": "Create and maintain an enabled **Activity Log alert** for **Service Health Incident** events.\n- Route via **Action Groups** to on-call channels\n- Filter to critical services/regions\n- Test routing and refine recipients regularly\n- Integrate with **incident response** and **defense-in-depth** monitoring", + "Url": "https://hub.prowler.com/check/monitor_alert_service_health_exists" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, in your Azure subscription there will not be any activity log alerts configured for Service Health events." diff --git a/prowler/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories.metadata.json b/prowler/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories.metadata.json index e577e0925a..af122f3206 100644 --- a/prowler/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_diagnostic_setting_with_appropriate_categories", - "CheckTitle": "Ensure Diagnostic Setting captures appropriate categories", + "CheckTitle": "Subscription has a diagnostic setting capturing Administrative, Security, Alert, and Policy categories", "CheckType": [], "ServiceName": "monitor", - "SubServiceName": "Configuring Diagnostic Settings", + "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Monitor", + "Severity": "high", + "ResourceType": "microsoft.resources/subscriptions", "ResourceGroup": "monitoring", - "Description": "Prerequisite: A Diagnostic Setting must exist. If a Diagnostic Setting does not exist, the navigation and options within this recommendation will not be available. Please review the recommendation at the beginning of this subsection titled: 'Ensure that a 'Diagnostic Setting' exists.' The diagnostic setting should be configured to log the appropriate activities from the control/management plane.", - "Risk": "A diagnostic setting controls how the diagnostic log is exported. Capturing the diagnostic setting categories for appropriate control/management plane activities allows proper alerting.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/diagnostic-settings", + "Description": "**Azure Monitor Diagnostic Settings** capture **control-plane events** at the subscription level. This evaluates whether at least one setting collects the categories: `Administrative`, `Security`, `Policy`, and `Alert`.", + "Risk": "Without these categories, critical control-plane actions may go unrecorded. Attackers could change policies, roles, or alerts unnoticed, enabling privilege escalation and resource tampering. This erodes **integrity**, threatens **confidentiality**, and weakens **availability** and **incident response**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/diagnostic-settings", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Monitor/diagnostic-setting-categories.html", + "https://learn.microsoft.com/en-us/azure/storage/common/manage-storage-analytics-logs?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&bc=%2Fazure%2Fstorage%2Fblobs%2Fbreadcrumb%2Ftoc.json&tabs=azure-portal" + ], "Remediation": { "Code": { - "CLI": "az monitor diagnostic-settings subscription create --subscription --name --location <[- -event-hub --event-hub-auth-rule ] [-- storage-account ] [--workspace ] --logs '[{category:Security,enabled:true},{category:Administrative,enabled:true},{ca tegory:Alert,enabled:true},{category:Policy,enabled:true}]'>", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Monitor/diagnostic-setting-categories.html", - "Terraform": "" + "CLI": "az monitor diagnostic-settings subscription create --name --workspace --logs '[{\"category\":\"Administrative\",\"enabled\":true},{\"category\":\"Security\",\"enabled\":true},{\"category\":\"Alert\",\"enabled\":true},{\"category\":\"Policy\",\"enabled\":true}]'", + "NativeIaC": "```bicep\n// Create a subscription-level diagnostic setting capturing required categories\ntargetScope = 'subscription'\n\nresource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: ''\n properties: {\n workspaceId: '' // Critical: send Activity Log to this Log Analytics workspace\n logs: [\n { category: 'Administrative', enabled: true } // Critical: required category\n { category: 'Security', enabled: true } // Critical: required category\n { category: 'Alert', enabled: true } // Critical: required category\n { category: 'Policy', enabled: true } // Critical: required category\n ]\n }\n}\n```", + "Other": "1. In Azure portal, go to Monitor > Activity log\n2. Click Diagnostic settings > Add diagnostic setting\n3. Name the setting\n4. Under Categories, check: Administrative, Security, Alert, Policy\n5. Under Destination, select Send to Log Analytics workspace and choose your workspace\n6. Click Save", + "Terraform": "```hcl\n# Subscription Activity Log diagnostic setting capturing required categories\nresource \"azurerm_monitor_diagnostic_setting\" \"example\" {\n name = \"\"\n target_resource_id = \"/subscriptions/\" # Critical: scope set to the subscription\n log_analytics_workspace_id = \"\" # Critical: destination workspace\n\n enabled_log { category = \"Administrative\" } # Critical: required category\n enabled_log { category = \"Security\" } # Critical: required category\n enabled_log { category = \"Alert\" } # Critical: required category\n enabled_log { category = \"Policy\" } # Critical: required category\n}\n```" }, "Recommendation": { - "Text": "1. Go to Azure Monitor 2. Click Activity log 3. Click on Export Activity Logs 4. Select the Subscription from the drop down menu 5. Click on Add diagnostic setting 6. Enter a name for your new Diagnostic Setting 7. Check the following categories: Administrative, Alert, Policy, and Security 8. Choose the destination details according to your organization's needs.", - "Url": "https://learn.microsoft.com/en-us/azure/storage/common/manage-storage-analytics-logs?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&bc=%2Fazure%2Fstorage%2Fblobs%2Fbreadcrumb%2Ftoc.json&tabs=azure-portal" + "Text": "Collect `Administrative`, `Security`, `Policy`, and `Alert` via a subscription diagnostic setting and route them to a centralized, tamper-resistant destination. Enforce **least privilege** on log access, set retention, and create **alerts** for high-risk changes as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/monitor_diagnostic_setting_with_appropriate_categories" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "When the diagnostic setting is created using Azure Portal, by default no categories are selected." diff --git a/prowler/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists.metadata.json b/prowler/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists.metadata.json index 1e9ac7891c..21462248d7 100644 --- a/prowler/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "monitor_diagnostic_settings_exists", - "CheckTitle": "Ensure that a 'Diagnostic Setting' exists for Subscription Activity Logs ", + "CheckTitle": "Subscription has an Activity Log diagnostic setting", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Monitor", + "Severity": "high", + "ResourceType": "microsoft.resources/subscriptions", "ResourceGroup": "monitoring", - "Description": "Enable Diagnostic settings for exporting activity logs. Diagnostic settings are available for each individual resource within a subscription. Settings should be configured for all appropriate resources for your environment.", - "Risk": "A diagnostic setting controls how a diagnostic log is exported. By default, logs are retained only for 90 days. Diagnostic settings should be defined so that logs can be exported and stored for a longer duration in order to analyze security activities within an Azure subscription.", - "RelatedUrl": "https://learn.microsoft.com/en-us/cli/azure/monitor/diagnostic-settings?view=azure-cli-latest", + "Description": "**Azure Monitor Diagnostic Settings** are configured to export the **Activity Log** to an external destination (Log Analytics, Storage, Event Hub, or partner).", + "Risk": "Without exporting the **Activity Log**, control-plane events lack **centralization and retention**.\n\nUndetected RBAC changes, policy updates, and resource deletions reduce **detectability**, hinder **forensics**, and weaken incident response and audit evidence.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/diagnostic-settings?WT.mc_id=AZ-MVP-5003450&tabs=portal", + "https://learn.microsoft.com/en-us/azure/azure-monitor/fundamentals/data-sources#export-the-activity-log-with-a-log-profile", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Monitor/subscription-activity-log-diagnostic-settings.html", + "https://learn.microsoft.com/en-us/cli/azure/monitor/diagnostic-settings?view=azure-cli-latest" + ], "Remediation": { "Code": { - "CLI": "az monitor diagnostic-settings subscription create --subscription --name --location <[- -event-hub --event-hub-auth-rule ] [-- storage-account ] [--workspace ] --logs '' (e.g. [{category:Security,enabled:true},{category:Administrative,enabled:true},{cat egory:Alert,enabled:true},{category:Policy,enabled:true}])", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Monitor/subscription-activity-log-diagnostic-settings.html#trendmicro", - "Terraform": "" + "CLI": "az monitor diagnostic-settings subscription create --subscription --name --workspace --logs '[{\"category\":\"Administrative\",\"enabled\":true}]'", + "NativeIaC": "```bicep\n// Subscription-level Activity Log diagnostic setting\nresource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: ''\n scope: subscription() // CRITICAL: targets the subscription Activity Log\n properties: {\n workspaceId: '' // CRITICAL: sends logs to this Log Analytics workspace\n logs: [\n { category: 'Administrative', enabled: true } // CRITICAL: enables at least one Activity Log category\n ]\n }\n}\n```", + "Other": "1. In the Azure portal, go to Subscriptions and select your subscription\n2. Open Monitoring > Activity log, then click Diagnostic settings\n3. Click + Add diagnostic setting and enter a name\n4. Under Destination details, select Send to Log Analytics workspace and choose your workspace\n5. Under Categories, select Administrative\n6. Click Save", + "Terraform": "```hcl\n# Subscription-level Activity Log diagnostic setting\nresource \"azurerm_monitor_diagnostic_setting\" \"\" {\n name = \"\"\n target_resource_id = \"/subscriptions/\" # CRITICAL: subscription scope\n log_analytics_workspace_id = \"\" # CRITICAL: destination workspace\n\n log {\n category = \"Administrative\" # CRITICAL: enable at least one Activity Log category\n enabled = true\n }\n}\n```" }, "Recommendation": { - "Text": "To enable Diagnostic Settings on a Subscription: 1. Go to Monitor 2. Click on Activity Log 3. Click on Export Activity Logs 4. Click + Add diagnostic setting 5. Enter a Diagnostic setting name 6. Select Categories for the diagnostic settings 7. Select the appropriate Destination details (this may be Log Analytics, Storage Account, Event Hub, or Partner solution) 8. Click Save To enable Diagnostic Settings on a specific resource: 1. Go to Monitor 2. Click Diagnostic settings 3. Click on the resource that has a diagnostics status of disabled 4. Select Add Diagnostic Setting 5. Enter a Diagnostic setting name 6. Select the appropriate log, metric, and destination. (this may be Log Analytics, Storage Account, Event Hub, or Partner solution) 7. Click save Repeat these step for all resources as needed.", - "Url": "https://docs.microsoft.com/en-us/azure/monitoring-and-diagnostics/monitoring-overview-activity-logs#export-the-activity-log-with-a-log-profile" + "Text": "Enable **subscription Diagnostic Settings** to send the **Activity Log** to a trusted destination.\n\nUse **immutable storage** or a **SIEM**, enforce coverage with **Azure Policy**, apply **least privilege** to log access, include essential categories, and set retention aligned to regulatory needs.", + "Url": "https://hub.prowler.com/check/monitor_diagnostic_settings_exists" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, diagnostic setting is not set." diff --git a/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted.metadata.json b/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted.metadata.json index 3ea864bc52..f5fd1d23b3 100644 --- a/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "monitor_storage_account_with_activity_logs_cmk_encrypted", - "CheckTitle": "Ensure the storage account containing the container with activity logs is encrypted with Customer Managed Key", + "CheckTitle": "Storage account storing Activity Log data is encrypted with a customer-managed key", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Monitor", + "ResourceType": "microsoft.storage/storageaccounts", "ResourceGroup": "monitoring", - "Description": "Storage accounts with the activity log exports can be configured to use CustomerManaged Keys (CMK).", - "Risk": "Configuring the storage account with the activity log export container to use CMKs provides additional confidentiality controls on log data, as a given user must have read permission on the corresponding storage account and must be granted decrypt permission by the CMK.", - "RelatedUrl": "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-5-encrypt-sensitive-data-at-rest", + "Description": "**Azure Monitor Activity Logs** sent to a **Storage account** are evaluated to confirm encryption with **Customer-Managed Keys** (`CMK`) instead of **Microsoft-managed keys**.", + "Risk": "Storing activity logs without **CMK** weakens confidentiality and control of audit data. You lose independent key ownership, limiting rapid rotation/revocation and separation of duties. If storage credentials are compromised, attackers can exfiltrate logs that map resources and changes, aiding targeted attacks and hindering effective incident response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/activity-log?tabs=cli#managing-legacy-log-profiles", + "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-5-encrypt-sensitive-data-at-rest", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Monitor/use-cmk-for-activity-log-storage-container-encryption.html" + ], "Remediation": { "Code": { - "CLI": "az storage account update --name --resource-group --encryption-key-source=Microsoft.Keyvault --encryption-key-vault --encryption-key-name --encryption-key-version ", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Monitor/use-cmk-for-activity-log-storage-container-encryption.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-storage-accounts-use-customer-managed-key-for-encryption#terraform" + "CLI": "az storage account update --name --resource-group --assign-identity --encryption-key-source Microsoft.Keyvault --encryption-key-vault --encryption-key-name ", + "NativeIaC": "```bicep\n// Storage account encrypted with a customer-managed key (CMK)\nresource stg 'Microsoft.Storage/storageAccounts@2023-01-01' = {\n name: ''\n location: resourceGroup().location\n kind: 'StorageV2'\n sku: { name: 'Standard_LRS' }\n identity: { type: 'SystemAssigned' } // Required for Storage to access the Key Vault key\n properties: {\n encryption: {\n keySource: 'Microsoft.Keyvault' // CRITICAL: switches encryption from Microsoft.Storage to CMK\n keyVaultProperties: {\n keyName: ''\n keyVaultUri: '' // Uses latest key version if not specified\n }\n }\n }\n}\n```", + "Other": "1. In the Azure portal, go to Storage accounts and open the account used by your Activity Log diagnostic setting\n2. Select Identity > System assigned > set Status to On > Save\n3. Go to Settings > Encryption\n4. Select Customer-managed keys, choose your Key vault and Key, then click Save\n5. Ensure the storage account's identity has Get, Wrap Key, and Unwrap Key permissions on the key in Key Vault", + "Terraform": "```hcl\n# Storage account encrypted with a customer-managed key (CMK)\nresource \"azurerm_storage_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n account_tier = \"Standard\"\n account_replication_type = \"LRS\"\n\n identity {\n type = \"SystemAssigned\" # Required for Storage to access the Key Vault key\n }\n\n customer_managed_key {\n key_vault_key_id = \"\" # CRITICAL: enables CMK by pointing to the Key Vault key\n }\n}\n```" }, "Recommendation": { - "Text": "1. Go to Activity log 2. Select Export 3. Select Subscription 4. In section Storage Account, note the name of the Storage account 5. Close the Export Audit Logs blade. Close the Monitor - Activity Log blade. 6. In right column, Click service Storage Accounts to access Storage account blade 7. Click on the storage account name noted in step 4. This will open blade specific to that storage account 8. Under Security + networking, click Encryption. 9. Ensure Customer-managed keys is selected and Key URI is set.", - "Url": "https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/activity-log?tabs=cli#managing-legacy-log-profiles" + "Text": "Encrypt the storage account that holds exported **Activity Logs** with **Customer-Managed Keys** via Azure Key Vault or Managed HSM. Apply **least privilege** to key usage, enforce regular rotation and revocation, and enable soft delete and purge protection. Complement with network isolation and immutable retention for **defense in depth**.", + "Url": "https://hub.prowler.com/check/monitor_storage_account_with_activity_logs_cmk_encrypted" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "NOTE: You must have your key vault setup to utilize this. All Audit Logs will be encrypted with a key you provide. You will need to set up customer managed keys separately, and you will select which key to use via the instructions here. You will be responsible for the lifecycle of the keys, and will need to manually replace them at your own determined intervals to keep the data secure." diff --git a/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private.metadata.json b/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private.metadata.json index e91472c240..6d44bef7d5 100644 --- a/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private.metadata.json +++ b/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "monitor_storage_account_with_activity_logs_is_private", - "CheckTitle": "Ensure the Storage Container Storing the Activity Logs is not Publicly Accessible", + "CheckTitle": "Storage account storing activity logs does not allow public blob access", "CheckType": [], "ServiceName": "monitor", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Monitor", + "ResourceType": "microsoft.storage/storageaccounts", "ResourceGroup": "monitoring", - "Description": "The storage account container containing the activity log export should not be publicly accessible.", - "Risk": "Allowing public access to activity log content may aid an adversary in identifying weaknesses in the affected account's use or configuration.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/diagnostic-settings", + "Description": "**Azure Monitor Activity Logs** sent to a **Storage account** are evaluated for **Blob public access**. The finding identifies whether the account that stores the logs has `AllowBlobPublicAccess` turned on.", + "Risk": "Exposed log data undermines **confidentiality** by revealing operations, resource IDs, IPs, and identities.\n\nAdversaries gain **reconnaissance** to map controls, craft targeted attacks, and time actions to avoid detection, enabling **lateral movement** and broader compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/diagnostic-settings", + "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-network-security#ns-2-secure-cloud-services-with-network-controls", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Monitor/check-for-publicly-accessible-activity-log-storage-container.html" + ], "Remediation": { "Code": { - "CLI": "az storage container set-permission --name insights-activity-logs --account-name --public-access off", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Monitor/check-for-publicly-accessible-activity-log-storage-container.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-logging-policies/ensure-the-storage-container-storing-the-activity-logs-is-not-publicly-accessible#terraform" + "CLI": "az storage account update --name --resource-group --allow-blob-public-access false", + "NativeIaC": "```bicep\n// Set storage account to disallow public blob access\nresource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {\n name: ''\n location: resourceGroup().location\n sku: { name: 'Standard_LRS' }\n kind: 'StorageV2'\n properties: {\n allowBlobPublicAccess: false // Critical: disables public access at the account level\n }\n}\n```", + "Other": "1. In Azure Portal, go to the storage account used by the diagnostic/Activity Log export\n2. Under Settings, select Configuration\n3. Set \"Allow Blob public access\" to Disabled\n4. Click Save", + "Terraform": "```hcl\n# Disable public blob access on the storage account\nresource \"azurerm_storage_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n account_tier = \"Standard\"\n account_replication_type = \"LRS\"\n\n allow_blob_public_access = false # Critical: disables public access at the account level\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu 2. Search for Storage Accounts to access Storage account blade 3. Click on the storage account name 4. Click on Configuration under settings 5. Select Enabled under 'Allow Blob public access'", - "Url": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-network-security#ns-2-secure-cloud-services-with-network-controls" + "Text": "Set `AllowBlobPublicAccess=false` on the storage account holding logs. Enforce **least privilege** via RBAC or scoped SAS, use **private endpoints** and network restrictions, and enable **immutability** for log containers to add **defense in depth** and prevent unauthorized access.", + "Url": "https://hub.prowler.com/check/monitor_storage_account_with_activity_logs_is_private" } }, - "Categories": [], + "Categories": [ + "internet-exposed", + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Configuring container Access policy to private will remove access from the container for everyone except owners of the storage account. Access policy needs to be set explicitly in order to allow access to other desired users." diff --git a/prowler/providers/cloudflare/cloudflare_provider.py b/prowler/providers/cloudflare/cloudflare_provider.py index 689d2da2b1..4773501ce3 100644 --- a/prowler/providers/cloudflare/cloudflare_provider.py +++ b/prowler/providers/cloudflare/cloudflare_provider.py @@ -1,3 +1,4 @@ +import logging import os from typing import Iterable @@ -55,6 +56,9 @@ class CloudflareProvider(Provider): ): logger.info("Instantiating Cloudflare provider...") + # Mute HPACK library logs to prevent token leakage in debug mode + logging.getLogger("hpack").setLevel(logging.CRITICAL) + if config_content: self._audit_config = config_content else: diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index 384c0b9de0..50b1ab650a 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -1,3 +1,4 @@ +import logging import os from os import environ from typing import Union @@ -134,8 +135,6 @@ class GithubProvider(Provider): logger.info("Instantiating GitHub Provider...") # Mute GitHub library logs to reduce noise since it is already handled by the Prowler logger - import logging - logging.getLogger("github").setLevel(logging.CRITICAL) logging.getLogger("github.GithubRetry").setLevel(logging.CRITICAL) diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index 244de81e08..88570f0fe7 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -121,15 +121,22 @@ class Repository(GithubService): ) ): if self.provider.repositories: - logger.info( - f"Filtering for specific repositories: {self.provider.repositories}" - ) + qualified_repos = [] for repo_name in self.provider.repositories: - if not self._validate_repository_format(repo_name): + if self._validate_repository_format(repo_name): + qualified_repos.append(repo_name) + elif self.provider.organizations: + for org_name in self.provider.organizations: + qualified_repos.append(f"{org_name}/{repo_name}") + else: logger.warning( f"Repository name '{repo_name}' should be in 'owner/repo-name' format. Skipping." ) - continue + + logger.info( + f"Filtering for specific repositories: {qualified_repos}" + ) + for repo_name in qualified_repos: try: repo = client.get_repo(repo_name) self._process_repository(repo, repos) @@ -138,7 +145,7 @@ class Repository(GithubService): error, "accessing repository", repo_name ) - if self.provider.organizations: + elif self.provider.organizations: logger.info( f"Filtering for repositories in organizations: {self.provider.organizations}" ) diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 81202b5a84..9cc7207f20 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -1,4 +1,7 @@ import os +import re + +from typing_extensions import override from prowler.lib.logger import logger from prowler.lib.powershell.powershell import PowerShellSession @@ -46,6 +49,28 @@ class M365PowerShell(PowerShellSession): self.tenant_identity = identity self.init_credential(credentials) + @override + def _process_error(self, error_result: str) -> None: + """ + Process PowerShell errors with M365-specific handling. + + Detects cmdlet not found errors which typically indicate missing licensing + (e.g., Microsoft Defender for Office 365) or insufficient permissions. + + Args: + error_result (str): The error output from the PowerShell command. + """ + if "is not recognized as a name of a cmdlet" in error_result: + cmdlet_match = re.search(r"'([^']+)'.*is not recognized", error_result) + cmdlet_name = cmdlet_match.group(1) if cmdlet_match else "Unknown" + logger.warning( + f"PowerShell cmdlet '{cmdlet_name}' is not available. " + f"This may indicate missing module, licensing (e.g., Microsoft Defender for Office 365) " + f"or insufficient permissions. Related checks will be skipped." + ) + else: + super()._process_error(error_result) + def clean_certificate_content(self, cert_content: str) -> str: """ Clean certificate content for PowerShell consumption. @@ -823,6 +848,78 @@ class M365PowerShell(PowerShellSession): "Get-SharingPolicy | ConvertTo-Json -Depth 10", json_parse=True ) + def get_safe_attachments_policy(self) -> dict: + """ + Get Safe Attachments Policy. + + Retrieves the Safe Attachments policy settings for Microsoft Defender for Office 365. + + Returns: + dict: Safe Attachments policy settings in JSON format. + + Example: + >>> get_safe_attachments_policy() + { + "Name": "Built-In Protection Policy", + "Identity": "Built-In Protection Policy", + "Enable": true, + "Action": "Block", + "QuarantineTag": "AdminOnlyAccessPolicy" + } + """ + return self.execute( + "Get-SafeAttachmentPolicy | ConvertTo-Json -Depth 10", json_parse=True + ) + + def get_safe_attachments_rule(self) -> dict: + """ + Get Safe Attachments Rules. + + Retrieves the Safe Attachments rules that define which users, groups, + and domains are targeted by Safe Attachments policies. + + Returns: + dict: Safe Attachments rules in JSON format. + + Example: + >>> get_safe_attachments_rule() + { + "Name": "Custom Safe Attachments Rule", + "SafeAttachmentPolicy": "Custom Policy", + "State": "Enabled", + "Priority": 0, + "SentTo": ["user@contoso.com"], + "SentToMemberOf": ["group@contoso.com"], + "RecipientDomainIs": ["contoso.com"] + } + """ + return self.execute( + "Get-SafeAttachmentRule | ConvertTo-Json -Depth 10", json_parse=True + ) + + def get_advanced_threat_protection_policy(self) -> dict: + """ + Get Advanced Threat Protection Policy. + + Retrieves the current Advanced Threat Protection policy settings, + including Safe Attachments for SharePoint, OneDrive, and Teams, and Safe Documents settings. + + Returns: + dict: Advanced Threat Protection policy settings in JSON format. + + Example: + >>> get_advanced_threat_protection_policy() + { + "Identity": "Default", + "EnableATPForSPOTeamsODB": true, + "EnableSafeDocs": true, + "AllowSafeDocsOpen": false + } + """ + return self.execute( + "Get-AtpPolicyForO365 | ConvertTo-Json -Depth 10", json_parse=True + ) + def get_teams_protection_policy(self) -> dict: """ Get Teams Protection Policy. @@ -883,6 +980,57 @@ class M365PowerShell(PowerShellSession): json_parse=True, ) + def get_safe_links_policy(self) -> dict: + """ + Get Safe Links Policy. + + Retrieves the current Safe Links policy settings for Microsoft Defender for Office 365. + + Returns: + dict: Safe Links policy settings in JSON format. + + Example: + >>> get_safe_links_policy() + { + "Name": "Built-In Protection Policy", + "Identity": "Built-In Protection Policy", + "EnableSafeLinksForEmail": true, + "EnableSafeLinksForTeams": true, + "EnableSafeLinksForOffice": true, + "TrackClicks": true, + "AllowClickThrough": false, + "ScanUrls": true, + "EnableForInternalSenders": true, + "DeliverMessageAfterScan": true, + "DisableUrlRewrite": false + } + """ + return self.execute( + "Get-SafeLinksPolicy | ConvertTo-Json -Depth 10", json_parse=True + ) + + def get_safe_links_rule(self) -> dict: + """ + Get Safe Links Rule. + + Retrieves the current Safe Links rule settings for Microsoft Defender for Office 365. + + Returns: + dict: Safe Links rule settings in JSON format. + + Example: + >>> get_safe_links_rule() + { + "Name": "Safe Links Rule", + "State": "Enabled", + "Priority": 0, + "SafeLinksPolicy": "Policy Name" + } + """ + return self.execute( + "Get-SafeLinksRule | ConvertTo-Json -Depth 10", json_parse=True + ) + # This function is used to install the required M365 PowerShell modules in Docker containers def initialize_m365_powershell_modules(): diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index f6ef545a7c..0e3a6c2b26 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -1,5 +1,6 @@ import asyncio import base64 +import logging import os from argparse import ArgumentTypeError from os import getenv @@ -157,6 +158,9 @@ class M365Provider(Provider): """ logger.info("Setting M365 provider ...") + # Mute HPACK library logs to prevent token leakage in debug mode + logging.getLogger("hpack").setLevel(logging.CRITICAL) + logger.info("Checking if any credentials mode is set ...") # Validate the authentication arguments diff --git a/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/__init__.py b/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.metadata.json b/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.metadata.json new file mode 100644 index 0000000000..5b69d64b5f --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "defender_atp_safe_attachments_and_docs_configured", + "CheckTitle": "ATP Safe Attachments policy has Safe Documents enabled and click-through blocked for SharePoint, OneDrive, and Microsoft Teams", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Defender ATP Policy", + "ResourceGroup": "security", + "Description": "**Safe Attachments** for SharePoint, OneDrive, and Microsoft Teams protects organizations from inadvertently sharing malicious files. When enabled, files identified as malicious are blocked and cannot be opened, copied, moved, or shared until further actions are taken by the organization's security team.", + "Risk": "Without **Safe Attachments** enabled, users may download, sync, or access **malicious files** from SharePoint, OneDrive, or Teams, potentially leading to **malware infections** across the organization. If **Safe Documents** is disabled or users can bypass **Protected View**, malicious content in Office documents may execute and **compromise endpoints**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-about", + "https://learn.microsoft.com/en-us/defender-office-365/safe-documents-in-e5-plus-security-about" + ], + "Remediation": { + "Code": { + "CLI": "Set-AtpPolicyForO365 -EnableATPForSPOTeamsODB $true -EnableSafeDocs $true -AllowSafeDocsOpen $false", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft 365 Defender portal at https://security.microsoft.com.\n2. Go to **Email & Collaboration** > **Policies & Rules** > **Threat policies**.\n3. Under **Policies**, select **Safe Attachments**.\n4. Click on **Global settings**.\n5. Enable **Turn on Defender for Office 365 for SharePoint, OneDrive, and Microsoft Teams**.\n6. Enable **Turn on Safe Documents for Office clients**.\n7. Disable **Allow people to click through Protected View even if Safe Documents identified the file as malicious**.\n8. Click **Save**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Safe Attachments** for SharePoint, OneDrive, and Microsoft Teams along with **Safe Documents** to protect users from malicious files. Block users from bypassing **Protected View** warnings for files identified as malicious to maintain **defense-in-depth**.", + "Url": "https://hub.prowler.com/check/defender_atp_safe_attachments_and_docs_configured" + } + }, + "Categories": [ + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check requires Microsoft Defender for Office 365 Plan 1 or Plan 2 (included in E5 license). Safe Documents specifically requires Microsoft 365 E5 or Microsoft 365 E5 Security." +} diff --git a/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.py b/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.py new file mode 100644 index 0000000000..fe51d08b73 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.py @@ -0,0 +1,62 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.defender.defender_client import defender_client + + +class defender_atp_safe_attachments_and_docs_configured(Check): + """ + Check if Safe Attachments for SharePoint, OneDrive, and Teams is properly configured. + + This check verifies that the ATP (Advanced Threat Protection) policy for Office 365 has: + - EnableATPForSPOTeamsODB = True (Safe Attachments enabled for SPO/OneDrive/Teams) + - EnableSafeDocs = True (Safe Documents enabled) + - AllowSafeDocsOpen = False (Users cannot bypass Protected View for malicious files) + + - PASS: All three settings are properly configured. + - FAIL: One or more settings are not properly configured. + """ + + def execute(self) -> List[CheckReportM365]: + """ + Execute the check to verify Safe Attachments ATP policy configuration. + + Returns: + List[CheckReportM365]: A list of reports containing the result of the check. + """ + findings = [] + + if defender_client.advanced_threat_protection_policy: + policy = defender_client.advanced_threat_protection_policy + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.identity, + resource_id=policy.identity, + ) + + # Check all three required settings + is_atp_enabled = policy.enable_atp_for_spo_teams_odb + is_safe_docs_enabled = policy.enable_safe_docs + is_safe_docs_open_blocked = not policy.allow_safe_docs_open + + if is_atp_enabled and is_safe_docs_enabled and is_safe_docs_open_blocked: + # Case 1: ATP policy exists and is properly configured + report.status = "PASS" + report.status_extended = f"ATP policy {policy.identity} has Safe Attachments for SharePoint, OneDrive, and Teams properly configured with Safe Documents enabled and click-through blocked." + else: + # Case 2: ATP policy exists but is not properly configured + report.status = "FAIL" + issues = [] + if not is_atp_enabled: + issues.append("Safe Attachments for SPO/OneDrive/Teams is disabled") + if not is_safe_docs_enabled: + issues.append("Safe Documents is disabled") + if not is_safe_docs_open_blocked: + issues.append("users can bypass Protected View for malicious files") + report.status_extended = f"ATP policy {policy.identity} is not properly configured: {'; '.join(issues)}." + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/__init__.py b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json new file mode 100644 index 0000000000..0a4176a0df --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "defender_safe_attachments_policy_enabled", + "CheckTitle": "Safe Attachments policy is enabled with secure configuration", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Defender Safe Attachments Policy", + "ResourceGroup": "security", + "Description": "Safe Attachments provides an additional layer of protection by scanning email attachments in a secure environment before delivering them to recipients.\n\nThe Built-In Protection Policy should have **Enable=True**, **Action=Block**, and **QuarantineTag=AdminOnlyAccessPolicy** to ensure malicious attachments are blocked and quarantined with admin-only access.", + "Risk": "Without properly configured Safe Attachments policies, malicious email attachments could reach users' mailboxes and potentially compromise the organization through:\n\n- **Malware delivery** via infected documents\n- **Ransomware attacks** through weaponized attachments\n- **Data exfiltration** using malicious scripts", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-about", + "https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies" + ], + "Remediation": { + "Code": { + "CLI": "Set-SafeAttachmentPolicy -Identity 'Built-In Protection Policy' -Enable $true -Action Block -QuarantineTag AdminOnlyAccessPolicy", + "NativeIaC": "", + "Other": "1. Go to Microsoft 365 Defender portal (https://security.microsoft.com)\n2. Navigate to Email & collaboration > Policies & rules > Threat policies\n3. Select Safe Attachments under Policies\n4. Click on Built-In Protection Policy\n5. Set Enable to True, Action to Block, and QuarantineTag to AdminOnlyAccessPolicy\n6. Save the configuration", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Safe Attachments with the **Block** action to prevent malicious attachments from reaching users. Use **AdminOnlyAccessPolicy** for the quarantine tag to ensure only administrators can release quarantined items, following the principle of least privilege.", + "Url": "https://hub.prowler.com/check/defender_safe_attachments_policy_enabled" + } + }, + "Categories": [ + "email-security", + "e5" + ], + "DependsOn": [], + "RelatedTo": [ + "defender_malware_policy_common_attachments_filter_enabled" + ], + "Notes": "This check requires Microsoft Defender for Office 365 Plan 1 or higher license." +} diff --git a/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.py b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.py new file mode 100644 index 0000000000..68714882f1 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.py @@ -0,0 +1,112 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.defender.defender_client import defender_client + + +class defender_safe_attachments_policy_enabled(Check): + """ + Check if Safe Attachments policy is properly configured in Microsoft Defender for Office 365. + + This check verifies that Safe Attachments policies have the following settings + configured according to CIS Microsoft 365 Foundations Benchmark: + + - Enable = True + - Action = Block + - QuarantineTag = AdminOnlyAccessPolicy + + Note: The Built-in Protection Policy has fixed settings that cannot be changed + and always provides baseline protection. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + + if defender_client.safe_attachments_policies: + # Only Built-in Protection Policy exists (no custom policies with rules) + if not defender_client.safe_attachments_rules: + policy = next(iter(defender_client.safe_attachments_policies.values())) + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.name, + resource_id=policy.identity, + ) + + # Case 1: Only Built-in policy exists - always PASS (fixed settings) + report.status = "PASS" + report.status_extended = f"{policy.name} is the only Safe Attachments policy and provides baseline protection for all users." + findings.append(report) + + # Multiple Safe Attachments Policies (Built-in + custom policies) + else: + for ( + policy_name, + policy, + ) in defender_client.safe_attachments_policies.items(): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy_name, + resource_id=policy.identity, + ) + + if policy.is_built_in_protection: + # Case 2: Built-in policy with custom policies - always PASS + report.status = "PASS" + report.status_extended = ( + f"{policy_name} provides baseline Safe Attachments protection, " + f"but could be overridden by a misconfigured custom policy for specific users." + ) + findings.append(report) + else: + # Custom policy - check configuration + rule = defender_client.safe_attachments_rules.get(policy_name) + if not rule: + continue + + included_resources = [] + if rule.users: + included_resources.append(f"users: {', '.join(rule.users)}") + if rule.groups: + included_resources.append( + f"groups: {', '.join(rule.groups)}" + ) + if rule.domains: + included_resources.append( + f"domains: {', '.join(rule.domains)}" + ) + # If no users, groups, or domains specified, the policy applies to all recipients + included_resources_str = ( + "; ".join(included_resources) + if included_resources + else "all users" + ) + + if self._is_policy_properly_configured(policy, rule): + # Case 2: Custom policy is properly configured + report.status = "PASS" + report.status_extended = ( + f"Custom Safe Attachments policy {policy_name} is properly configured and includes {included_resources_str}, " + f"with priority {rule.priority} (0 is the highest)." + ) + else: + # Case 3: Custom policy is not properly configured + report.status = "FAIL" + report.status_extended = ( + f"Custom Safe Attachments policy {policy_name} is not properly configured and includes {included_resources_str}, " + f"with priority {rule.priority} (0 is the highest)." + ) + findings.append(report) + + return findings + + def _is_policy_properly_configured(self, policy, rule) -> bool: + """Check if a custom policy is properly configured according to CIS recommendations.""" + return ( + rule.state.lower() == "enabled" + and policy.enable + and policy.action == "Block" + and policy.quarantine_tag == "AdminOnlyAccessPolicy" + ) diff --git a/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/__init__.py b/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.metadata.json new file mode 100644 index 0000000000..cccebcfc7f --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "defender_safelinks_policy_enabled", + "CheckTitle": "Safe Links policy is enabled and properly configured in Microsoft Defender for Office 365.", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Defender Safe Links Policy", + "ResourceGroup": "security", + "Description": "Safe Links is a Microsoft Defender for Office 365 feature that provides URL scanning and rewriting of inbound email messages, as well as time-of-click verification of URLs and links in email messages, Teams, and Office apps.\n\nThis check verifies that the Safe Links policy is properly configured with all recommended settings enabled.", + "Risk": "Without properly configured Safe Links protection, users may be vulnerable to **phishing attacks** and **malicious URLs** in emails, Teams messages, and Office documents.\n\nAttackers could bypass security by using URLs that redirect to malicious content after initial scanning.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/safe-links-about", + "https://learn.microsoft.com/en-us/defender-office-365/safe-links-policies-configure" + ], + "Remediation": { + "Code": { + "CLI": "Set-SafeLinksPolicy -Identity 'Built-In Protection Policy' -EnableSafeLinksForEmail $true -EnableSafeLinksForTeams $true -EnableSafeLinksForOffice $true -TrackClicks $true -AllowClickThrough $false -ScanUrls $true -EnableForInternalSenders $true -DeliverMessageAfterScan $true -DisableUrlRewrite $false", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft 365 Defender at https://security.microsoft.com\n2. Click to expand Email & collaboration and select Policies & rules\n3. Select Threat policies\n4. Under Policies, select Safe Links\n5. Select or create a policy and configure these settings:\n - Enable Safe Links for Email: On\n - Enable Safe Links for Teams: On\n - Enable Safe Links for Office: On\n - Track user clicks: On\n - Let users click through to the original URL: Off\n - Scan URLs: On\n - Apply Safe Links to messages sent within the organization: On\n - Wait for URL scanning to complete before delivering the message: On\n - Do not rewrite URLs: Off\n6. Save the policy", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable and properly configure Safe Links policies to protect users from malicious URLs in emails, Teams, and Office applications. Ensure URL scanning and time-of-click verification are enabled across all communication channels.", + "Url": "https://hub.prowler.com/check/defender_safelinks_policy_enabled" + } + }, + "Categories": [ + "email-security", + "e5" + ], + "DependsOn": [], + "RelatedTo": [ + "defender_antiphishing_policy_configured" + ], + "Notes": "Safe Links requires Microsoft Defender for Office 365 Plan 1 (P1) or higher licensing." +} diff --git a/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.py b/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.py new file mode 100644 index 0000000000..575397309a --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.py @@ -0,0 +1,121 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.defender.defender_client import defender_client + + +class defender_safelinks_policy_enabled(Check): + """ + Check if Safe Links policy is enabled and properly configured in Microsoft Defender for Office 365. + + This check verifies that Safe Links policies have the following settings + configured according to CIS Microsoft 365 Foundations Benchmark: + + - EnableSafeLinksForEmail = True + - EnableSafeLinksForTeams = True + - EnableSafeLinksForOffice = True + - TrackClicks = True + - AllowClickThrough = False + - ScanUrls = True + - EnableForInternalSenders = True + - DeliverMessageAfterScan = True + - DisableUrlRewrite = False + + Note: The Built-in Protection Policy has fixed settings that cannot be changed + and always provides baseline protection. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + + if defender_client.safe_links_policies: + # Only Built-in Protection Policy exists (no custom policies with rules) + if not defender_client.safe_links_rules: + policy = next(iter(defender_client.safe_links_policies.values())) + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.name, + resource_id=policy.identity, + ) + + # Case 1: Only Built-in policy exists - always PASS (fixed settings) + report.status = "PASS" + report.status_extended = f"{policy.name} is the only Safe Links policy and provides baseline protection for all users." + findings.append(report) + + # Multiple Safe Links Policies (Built-in + custom policies) + else: + for policy_name, policy in defender_client.safe_links_policies.items(): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy_name, + resource_id=policy.identity, + ) + + if policy.is_built_in_protection: + # Case 2: Built-in policy with custom policies - always PASS + report.status = "PASS" + report.status_extended = ( + f"{policy_name} provides baseline Safe Links protection, " + f"but could be overridden by a misconfigured custom policy for specific users." + ) + findings.append(report) + else: + # Custom policy - check configuration + rule = defender_client.safe_links_rules.get(policy_name) + if not rule: + continue + + included_resources = [] + if rule.users: + included_resources.append(f"users: {', '.join(rule.users)}") + if rule.groups: + included_resources.append( + f"groups: {', '.join(rule.groups)}" + ) + if rule.domains: + included_resources.append( + f"domains: {', '.join(rule.domains)}" + ) + # If no users, groups, or domains specified, the policy applies to all recipients + included_resources_str = ( + "; ".join(included_resources) + if included_resources + else "all users" + ) + + if self._is_policy_properly_configured(policy, rule): + # Case 2: Custom policy is properly configured + report.status = "PASS" + report.status_extended = ( + f"Custom Safe Links policy {policy_name} is properly configured and includes {included_resources_str}, " + f"with priority {rule.priority} (0 is the highest)." + ) + else: + # Case 3: Custom policy is not properly configured + report.status = "FAIL" + report.status_extended = ( + f"Custom Safe Links policy {policy_name} is not properly configured and includes {included_resources_str}, " + f"with priority {rule.priority} (0 is the highest)." + ) + findings.append(report) + + return findings + + def _is_policy_properly_configured(self, policy, rule) -> bool: + """Check if a custom policy is properly configured according to CIS recommendations.""" + return ( + rule.state.lower() == "enabled" + and policy.enable_safe_links_for_email + and policy.enable_safe_links_for_teams + and policy.enable_safe_links_for_office + and policy.track_clicks + and not policy.allow_click_through + and policy.scan_urls + and policy.enable_for_internal_senders + and policy.deliver_message_after_scan + and not policy.disable_url_rewrite + ) diff --git a/prowler/providers/m365/services/defender/defender_service.py b/prowler/providers/m365/services/defender/defender_service.py index 09aa39986d..ba377e2b79 100644 --- a/prowler/providers/m365/services/defender/defender_service.py +++ b/prowler/providers/m365/services/defender/defender_service.py @@ -9,18 +9,38 @@ from prowler.providers.m365.m365_provider import M365Provider class Defender(M365Service): """ - Microsoft 365 Defender service implementation. + Microsoft Defender for Office 365 service class. - Provides access to Microsoft Defender for Office 365 configurations including - malware policies, spam filtering, anti-phishing, and Teams protection settings. + This class provides methods to retrieve and manage Microsoft Defender for Office 365 + security policies and configurations, including malware filters, spam policies, + anti-phishing settings, Safe Attachments, Safe Links, ATP (Advanced Threat Protection), + and Teams protection policies. + + Attributes: + malware_policies (list): List of malware filter policies. + outbound_spam_policies (dict): Dictionary of outbound spam filter policies. + outbound_spam_rules (dict): Dictionary of outbound spam filter rules. + antiphishing_policies (dict): Dictionary of anti-phishing policies. + antiphishing_rules (dict): Dictionary of anti-phishing rules. + connection_filter_policy: Connection filter policy configuration. + dkim_configurations (list): List of DKIM signing configurations. + inbound_spam_policies (list): List of inbound spam filter policies. + inbound_spam_rules (dict): Dictionary of inbound spam filter rules. + report_submission_policy: Report submission policy configuration. + safe_attachments_policies (dict): Dictionary of Safe Attachments policies. + safe_attachments_rules (dict): Dictionary of Safe Attachments rules. + advanced_threat_protection_policy: Advanced Threat Protection policy configuration. + safe_links_policies (dict): Dictionary of Safe Links policies. + safe_links_rules (dict): Dictionary of Safe Links rules. + teams_protection_policy: Teams protection policy configuration. """ def __init__(self, provider: M365Provider): """ - Initialize the Defender service. + Initialize the Defender service client. Args: - provider: The M365 provider instance. + provider: The M365Provider instance for authentication and configuration. """ super().__init__(provider) self.malware_policies = [] @@ -33,6 +53,11 @@ class Defender(M365Service): self.inbound_spam_policies = [] self.inbound_spam_rules = {} self.report_submission_policy = None + self.safe_attachments_policies = {} + self.safe_attachments_rules = {} + self.advanced_threat_protection_policy = None + self.safe_links_policies = {} + self.safe_links_rules = {} self.teams_protection_policy = None if self.powershell: if self.powershell.connect_exchange_online(): @@ -47,6 +72,13 @@ class Defender(M365Service): self.inbound_spam_policies = self._get_inbound_spam_filter_policy() self.inbound_spam_rules = self._get_inbound_spam_filter_rule() self.report_submission_policy = self._get_report_submission_policy() + self.safe_attachments_policies = self._get_safe_attachments_policies() + self.safe_attachments_rules = self._get_safe_attachments_rules() + self.advanced_threat_protection_policy = ( + self._get_advanced_threat_protection_policy() + ) + self.safe_links_policies = self._get_safe_links_policy() + self.safe_links_rules = self._get_safe_links_rule() self.teams_protection_policy = self._get_teams_protection_policy() self.powershell.close() @@ -366,10 +398,10 @@ class Defender(M365Service): def _get_report_submission_policy(self): """ - Retrieve the Defender report submission policy. + Get the Defender report submission policy. Returns: - ReportSubmissionPolicy: The report submission policy configuration. + ReportSubmissionPolicy: The report submission policy configuration or None. """ logger.info("Microsoft365 - Getting Defender report submission policy...") report_submission_policy = None @@ -408,6 +440,179 @@ class Defender(M365Service): ) return report_submission_policy + def _get_safe_attachments_policies(self): + """ + Retrieve Safe Attachments policies from Microsoft Defender for Office 365. + + Returns: + dict[str, SafeAttachmentsPolicy]: A dictionary of Safe Attachments policies keyed by name. + """ + logger.info("Microsoft365 - Getting Defender Safe Attachments policies...") + safe_attachments_policies = {} + try: + policies_data = self.powershell.get_safe_attachments_policy() + if not policies_data: + return safe_attachments_policies + if isinstance(policies_data, dict): + policies_data = [policies_data] + for policy in policies_data: + if policy: + policy_name = policy.get("Name", "") + is_built_in = policy_name == "Built-In Protection Policy" + safe_attachments_policies[policy_name] = SafeAttachmentsPolicy( + name=policy_name, + identity=policy.get("Identity", ""), + enable=policy.get("Enable", False), + action=policy.get("Action", ""), + quarantine_tag=policy.get("QuarantineTag", ""), + redirect=policy.get("Redirect", False), + redirect_address=policy.get("RedirectAddress", ""), + is_built_in_protection=is_built_in, + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return safe_attachments_policies + + def _get_safe_attachments_rules(self): + """ + Retrieve Safe Attachments rules from Microsoft Defender for Office 365. + + Returns: + dict[str, SafeAttachmentsRule]: A dictionary of Safe Attachments rules keyed by policy name. + """ + logger.info("Microsoft365 - Getting Defender Safe Attachments rules...") + safe_attachments_rules = {} + try: + rules_data = self.powershell.get_safe_attachments_rule() + if not rules_data: + return safe_attachments_rules + if isinstance(rules_data, dict): + rules_data = [rules_data] + for rule in rules_data: + if rule: + policy_name = rule.get("SafeAttachmentPolicy", "") + safe_attachments_rules[policy_name] = SafeAttachmentsRule( + state=rule.get("State", ""), + priority=rule.get("Priority", 0), + users=rule.get("SentTo"), + groups=rule.get("SentToMemberOf"), + domains=rule.get("RecipientDomainIs"), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return safe_attachments_rules + + def _get_advanced_threat_protection_policy(self): + """ + Get the Advanced Threat Protection policy. + + Retrieves the ATP policy settings including Safe Attachments for SharePoint, + OneDrive, and Teams, as well as Safe Documents configuration. + + Returns: + AdvancedThreatProtectionPolicy: The Advanced Threat Protection policy configuration. + """ + logger.info("Microsoft365 - Getting Advanced Threat Protection policy...") + atp_policy = None + try: + policy = self.powershell.get_advanced_threat_protection_policy() + if policy: + atp_policy = AdvancedThreatProtectionPolicy( + identity=policy.get("Identity", "Default"), + enable_atp_for_spo_teams_odb=policy.get( + "EnableATPForSPOTeamsODB", False + ), + enable_safe_docs=policy.get("EnableSafeDocs", False), + allow_safe_docs_open=policy.get("AllowSafeDocsOpen", True), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return atp_policy + + def _get_safe_links_policy(self): + """ + Get Safe Links policies from Microsoft Defender for Office 365. + + Returns: + dict: A dictionary mapping policy names to SafeLinksPolicy objects. + """ + logger.info("Microsoft365 - Getting Defender Safe Links policies...") + safe_links_policies = {} + try: + safe_links_policy_data = self.powershell.get_safe_links_policy() + if not safe_links_policy_data: + return safe_links_policies + if isinstance(safe_links_policy_data, dict): + safe_links_policy_data = [safe_links_policy_data] + for policy in safe_links_policy_data: + if policy: + safe_links_policies[policy.get("Name", "")] = SafeLinksPolicy( + name=policy.get("Name", ""), + identity=policy.get("Identity", ""), + enable_safe_links_for_email=policy.get( + "EnableSafeLinksForEmail", False + ), + enable_safe_links_for_teams=policy.get( + "EnableSafeLinksForTeams", False + ), + enable_safe_links_for_office=policy.get( + "EnableSafeLinksForOffice", False + ), + track_clicks=policy.get("TrackClicks", False), + allow_click_through=policy.get("AllowClickThrough", True), + scan_urls=policy.get("ScanUrls", False), + enable_for_internal_senders=policy.get( + "EnableForInternalSenders", False + ), + deliver_message_after_scan=policy.get( + "DeliverMessageAfterScan", False + ), + disable_url_rewrite=policy.get("DisableUrlRewrite", True), + is_built_in_protection=policy.get("IsBuiltInProtection", False), + is_default=policy.get("IsDefault", False), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return safe_links_policies + + def _get_safe_links_rule(self): + """ + Get Safe Links rules from Microsoft Defender for Office 365. + + Returns: + dict: A dictionary mapping policy names to SafeLinksRule objects. + """ + logger.info("Microsoft365 - Getting Defender Safe Links rules...") + safe_links_rules = {} + try: + safe_links_rule_data = self.powershell.get_safe_links_rule() + if not safe_links_rule_data: + return safe_links_rules + if isinstance(safe_links_rule_data, dict): + safe_links_rule_data = [safe_links_rule_data] + for rule in safe_links_rule_data: + if rule: + safe_links_rules[rule.get("SafeLinksPolicy", "")] = SafeLinksRule( + state=rule.get("State", "Disabled"), + priority=rule.get("Priority", 0), + users=rule.get("SentTo", None), + groups=rule.get("SentToMemberOf", None), + domains=rule.get("RecipientDomainIs", None), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return safe_links_rules + def _get_teams_protection_policy(self): """ Retrieve the Teams protection policy including ZAP settings. @@ -513,7 +718,7 @@ class InboundSpamRule(BaseModel): class ReportSubmissionPolicy(BaseModel): - """Model for Defender report submission policy settings.""" + """Model for Defender report submission policy configuration.""" report_junk_to_customized_address: bool report_not_junk_to_customized_address: bool @@ -525,6 +730,97 @@ class ReportSubmissionPolicy(BaseModel): report_chat_message_to_customized_address_enabled: bool +class SafeAttachmentsPolicy(BaseModel): + """ + Data model for Safe Attachments policy settings. + + Attributes: + name: The name of the policy. + identity: The unique identifier of the policy. + enable: Whether the policy is enabled. + action: The action to take on malicious attachments (Allow, Block, Replace, DynamicDelivery). + quarantine_tag: The quarantine policy applied to detected messages. + redirect: Whether to redirect messages with detected attachments. + redirect_address: The email address to redirect messages to. + is_built_in_protection: Whether this is the Built-in Protection Policy. + """ + + name: str + identity: str + enable: bool + action: str + quarantine_tag: str + redirect: bool + redirect_address: str + is_built_in_protection: bool = False + + +class SafeAttachmentsRule(BaseModel): + """ + Data model for Safe Attachments rule settings. + + Attributes: + state: The state of the rule (Enabled/Disabled). + priority: The priority of the rule (0 is highest). + users: List of users the rule applies to. + groups: List of groups the rule applies to. + domains: List of domains the rule applies to. + """ + + state: str + priority: int + users: Optional[list[str]] + groups: Optional[list[str]] + domains: Optional[list[str]] + + +class AdvancedThreatProtectionPolicy(BaseModel): + """ + Model for Advanced Threat Protection policy. + + Attributes: + identity: The identity of the ATP policy. + enable_atp_for_spo_teams_odb: Whether Safe Attachments is enabled for + SharePoint, OneDrive, and Microsoft Teams. + enable_safe_docs: Whether Safe Documents is enabled for clients in Protected View. + allow_safe_docs_open: Whether users can click through Protected View + even if Safe Documents identifies the file as malicious. + """ + + identity: str + enable_atp_for_spo_teams_odb: bool + enable_safe_docs: bool + allow_safe_docs_open: bool + + +class SafeLinksPolicy(BaseModel): + """Model for Defender Safe Links Policy configuration.""" + + name: str + identity: str + enable_safe_links_for_email: bool + enable_safe_links_for_teams: bool + enable_safe_links_for_office: bool + track_clicks: bool + allow_click_through: bool + scan_urls: bool + enable_for_internal_senders: bool + deliver_message_after_scan: bool + disable_url_rewrite: bool + is_built_in_protection: bool + is_default: bool + + +class SafeLinksRule(BaseModel): + """Model for Defender Safe Links Rule configuration.""" + + state: str + priority: int + users: Optional[list[str]] + groups: Optional[list[str]] + domains: Optional[list[str]] + + class TeamsProtectionPolicy(BaseModel): """Model for Teams protection policy settings including ZAP configuration.""" diff --git a/skills/prowler-attack-paths-query/SKILL.md b/skills/prowler-attack-paths-query/SKILL.md new file mode 100644 index 0000000000..35fb9341d3 --- /dev/null +++ b/skills/prowler-attack-paths-query/SKILL.md @@ -0,0 +1,497 @@ +--- +name: prowler-attack-paths-query +description: > + Creates Prowler Attack Paths openCypher queries for graph analysis (compatible with Neo4j and Neptune). + Trigger: When creating or updating Attack Paths queries that detect privilege escalation paths, + network exposure, or security misconfigurations in cloud environments. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, api] + auto_invoke: + - "Creating Attack Paths queries" + - "Updating existing Attack Paths queries" + - "Adding privilege escalation detection queries" +allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, Task +--- + +## Overview + +Attack Paths queries are openCypher queries that analyze cloud infrastructure graphs (ingested via Cartography) to detect security risks like privilege escalation paths, network exposure, and misconfigurations. + +Queries are written in **openCypher Version 9** to ensure compatibility with both Neo4j and Amazon Neptune. + +--- + +## Input Sources + +Queries can be created from: + +1. **pathfinding.cloud ID** (e.g., `ECS-001`, `GLUE-001`) + - The JSON index contains: `id`, `name`, `description`, `services`, `permissions`, `exploitationSteps`, `prerequisites`, etc. + - Reference: https://github.com/DataDog/pathfinding.cloud + + **Fetching a single path by ID** - The aggregated `paths.json` is too large for WebFetch + (content gets truncated). Use Bash with `curl` and a JSON parser instead: + + Prefer `jq` (concise), fall back to `python3` (guaranteed in this Python project): + + ```bash + # With jq + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | jq '.[] | select(.id == "ecs-002")' + + # With python3 (fallback) + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | python3 -c "import json,sys; print(json.dumps(next((p for p in json.load(sys.stdin) if p['id']=='ecs-002'), None), indent=2))" + ``` + +2. **Listing Available Attack Paths** + - Use Bash to list available paths from the JSON index: + + ```bash + # List all path IDs and names (jq) + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | jq -r '.[] | "\(.id): \(.name)"' + + # List all path IDs and names (python3 fallback) + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | python3 -c "import json,sys; [print(f\"{p['id']}: {p['name']}\") for p in json.load(sys.stdin)]" + + # List paths filtered by service prefix + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | jq -r '.[] | select(.id | startswith("ecs")) | "\(.id): \(.name)"' + ``` + +3. **Natural Language Description** + - User describes the Attack Paths in plain language + - Agent maps to appropriate openCypher patterns + +--- + +## Query Structure + +### File Location + +``` +api/src/backend/api/attack_paths/queries/{provider}.py +``` + +Example: `api/src/backend/api/attack_paths/queries/aws.py` + +### Query Definition Pattern + +```python +from api.attack_paths.queries.types import ( + AttackPathsQueryAttribution, + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) +from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL + +# {REFERENCE_ID} (e.g., EC2-001, GLUE-001) +AWS_{QUERY_NAME} = AttackPathsQueryDefinition( + id="aws-{kebab-case-name}", + name="{Human-friendly label} ({REFERENCE_ID})", + short_description="{Brief explanation of the attack, no technical permissions.}", + description="{Detailed description of the attack vector and impact.}", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - {REFERENCE_ID} - {permission1} + {permission2}", + link="https://pathfinding.cloud/paths/{reference_id_lowercase}", + ), + provider="aws", + cypher=f""" + // Find principals with {permission1} + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = '{permission1_lowercase}' + OR toLower(action) = '{service}:*' + OR action = '*' + ) + + // Find {permission2} + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = '{permission2_lowercase}' + OR toLower(action) = '{service2}:*' + OR action = '*' + ) + + // Find target resources (MUST chain from `aws` for provider isolation) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: '{service}.amazonaws.com'}}) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) +``` + +### Register in Query List + +Add to the `{PROVIDER}_QUERIES` list at the bottom of the file: + +```python +AWS_QUERIES: list[AttackPathsQueryDefinition] = [ + # ... existing queries ... + AWS_{NEW_QUERY_NAME}, # Add here +] +``` + +--- + +## Step-by-Step Creation Process + +### 1. Read the Queries Module + +**FIRST**, read all files in the queries module to understand the structure: + +``` +api/src/backend/api/attack_paths/queries/ +├── __init__.py # Module exports +├── types.py # AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition +├── registry.py # Query registry logic +└── {provider}.py # Provider-specific queries (e.g., aws.py) +``` + +Read these files to learn: + +- Type definitions and available fields +- How queries are registered +- Current query patterns, style, and naming conventions + +### 2. Determine Schema Source + +Check the Cartography dependency in `api/pyproject.toml`: + +```bash +grep cartography api/pyproject.toml +``` + +Parse the dependency to determine the schema source: + +**If git-based dependency** (e.g., `cartography @ git+https://github.com/prowler-cloud/cartography@0.126.1`): + +- Extract the repository (e.g., `prowler-cloud/cartography`) +- Extract the version/tag (e.g., `0.126.1`) +- Fetch schema from that repository at that tag + +**If PyPI dependency** (e.g., `cartography = "^0.126.0"` or `cartography>=0.126.0`): + +- Extract the version (e.g., `0.126.0`) +- Use the official `cartography-cncf` repository + +**Schema URL patterns** (ALWAYS use the specific version tag, not master/main): + +``` +# Official Cartography (cartography-cncf) +https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md + +# Prowler fork (prowler-cloud) +https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md +``` + +**Examples**: + +```bash +# For prowler-cloud/cartography@0.126.1 (git), fetch AWS schema: +https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/0.126.1/docs/root/modules/aws/schema.md + +# For cartography = "^0.126.0" (PyPI), fetch AWS schema: +https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.126.0/docs/root/modules/aws/schema.md +``` + +**IMPORTANT**: Always match the schema version to the dependency version in `pyproject.toml`. Using master/main may reference node labels or properties that don't exist in the deployed version. + +**Additional Prowler Labels**: The Attack Paths sync task adds extra labels: + +- `ProwlerFinding` - Prowler finding nodes with `status`, `provider_uid` properties +- `ProviderResource` - Generic resource marker +- `{Provider}Resource` - Provider-specific marker (e.g., `AWSResource`) + +These are defined in `api/src/backend/tasks/jobs/attack_paths/config.py`. + +### 3. Consult the Schema for Available Data + +Use the Cartography schema to discover: + +- What node labels exist for the target resources +- What properties are available on those nodes +- What relationships connect the nodes + +This informs query design by showing what data is actually available to query. + +### 4. Create Query Definition + +Use the standard pattern (see above) with: + +- **id**: Auto-generated as `{provider}-{kebab-case-description}` +- **name**: Short, human-friendly label. No raw IAM permissions. For sourced queries (e.g., pathfinding.cloud), append the reference ID in parentheses: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. If the name already has parentheses, prepend the ID inside them: `"ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)"`. +- **short_description**: Brief explanation of the attack, no technical permissions. E.g., "Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS." +- **description**: Full technical explanation of the attack vector and impact. Plain text only, no HTML or technical permissions here. +- **provider**: Provider identifier (aws, azure, gcp, kubernetes, github) +- **cypher**: The openCypher query with proper escaping +- **parameters**: Optional list of user-provided parameters (use `parameters=[]` if none needed) +- **attribution**: Optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `text` includes the source, reference ID, and technical permissions (e.g., `"pathfinding.cloud - EC2-001 - iam:PassRole + ec2:RunInstances"`). The `link` is the URL with a lowercase ID (e.g., `"https://pathfinding.cloud/paths/ec2-001"`). Omit (defaults to `None`) for non-sourced queries. + +### 5. Add Query to Provider List + +Add the constant to the `{PROVIDER}_QUERIES` list. + +--- + +## Query Naming Conventions + +### Query ID + +``` +{provider}-{category}-{description} +``` + +Examples: + +- `aws-ec2-privesc-passrole-iam` +- `aws-iam-privesc-attach-role-policy-assume-role` +- `aws-rds-unencrypted-storage` + +### Query Constant Name + +``` +{PROVIDER}_{CATEGORY}_{DESCRIPTION} +``` + +Examples: + +- `AWS_EC2_PRIVESC_PASSROLE_IAM` +- `AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE` +- `AWS_RDS_UNENCRYPTED_STORAGE` + +--- + +## Query Categories + +| Category | Description | Example | +| -------------------- | ------------------------------ | ------------------------- | +| Basic Resource | List resources with properties | RDS instances, S3 buckets | +| Network Exposure | Internet-exposed resources | EC2 with public IPs | +| Privilege Escalation | IAM privilege escalation paths | PassRole + RunInstances | +| Data Access | Access to sensitive data | EC2 with S3 access | + +--- + +## Common openCypher Patterns + +### Match Account and Principal + +```cypher +MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) +``` + +### Check IAM Action Permissions + +```cypher +WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) +``` + +### Find Roles Trusting a Service + +```cypher +MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) +``` + +### Check Resource Scope + +```cypher +WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name +) +``` + +### Include Prowler Findings + +```cypher +UNWIND nodes(path_principal) + nodes(path_target) as n +OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {status: 'FAIL', provider_uid: $provider_uid}) + +RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr +``` + +--- + +## Common Node Labels by Provider + +### AWS + +| Label | Description | +| -------------------- | ----------------------------------- | +| `AWSAccount` | AWS account root | +| `AWSPrincipal` | IAM principal (user, role, service) | +| `AWSRole` | IAM role | +| `AWSUser` | IAM user | +| `AWSPolicy` | IAM policy | +| `AWSPolicyStatement` | Policy statement | +| `EC2Instance` | EC2 instance | +| `EC2SecurityGroup` | Security group | +| `S3Bucket` | S3 bucket | +| `RDSInstance` | RDS database instance | +| `LoadBalancer` | Classic ELB | +| `LoadBalancerV2` | ALB/NLB | +| `LaunchTemplate` | EC2 launch template | + +### Common Relationships + +| Relationship | Description | +| ---------------------- | ----------------------- | +| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | +| `STS_ASSUMEROLE_ALLOW` | Can assume role | +| `POLICY` | Has policy attached | +| `STATEMENT` | Policy has statement | + +--- + +## Parameters + +For queries requiring user input, define parameters: + +```python +parameters=[ + AttackPathsQueryParameterDefinition( + name="ip", + label="IP address", + description="Public IP address, e.g. 192.0.2.0.", + placeholder="192.0.2.0", + ), + AttackPathsQueryParameterDefinition( + name="tag_key", + label="Tag key", + description="Tag key to filter resources.", + placeholder="Environment", + ), +], +``` + +--- + +## Best Practices + +1. **Always filter by provider_uid**: Use `{id: $provider_uid}` on account nodes and `{provider_uid: $provider_uid}` on ProwlerFinding nodes + +2. **Use consistent naming**: Follow existing patterns in the file + +3. **Include Prowler findings**: Always add the OPTIONAL MATCH for ProwlerFinding nodes + +4. **Return distinct findings**: Use `collect(DISTINCT pf)` to avoid duplicates + +5. **Comment the query purpose**: Add inline comments explaining each MATCH clause + +6. **Validate schema first**: Ensure all node labels and properties exist in Cartography schema + +7. **Chain all MATCHes from the root account node**: Every `MATCH` clause must connect to the `aws` variable (or another variable already bound to the account's subgraph). The tenant database contains data from multiple providers — an unanchored `MATCH` would return nodes from all providers, breaking provider isolation. + + ```cypher + // WRONG: matches ALL AWSRoles across all providers in the tenant DB + MATCH (role:AWSRole) WHERE role.name = 'admin' + + // CORRECT: scoped to the specific account's subgraph + MATCH (aws)--(role:AWSRole) WHERE role.name = 'admin' + ``` + +--- + +## openCypher Compatibility + +Queries must be written in **openCypher Version 9** to ensure compatibility with both Neo4j and Amazon Neptune. + +> **Why Version 9?** Amazon Neptune implements openCypher Version 9. By targeting this specification, queries work on both Neo4j and Neptune without modification. + +### Avoid These (Not in openCypher spec) + +| Feature | Reason | +| --------------------------------------------------- | ----------------------------------------------- | +| APOC procedures (`apoc.*`) | Neo4j-specific plugin, not available in Neptune | +| Virtual nodes (`apoc.create.vNode`) | APOC-specific | +| Virtual relationships (`apoc.create.vRelationship`) | APOC-specific | +| Neptune extensions | Not available in Neo4j | +| `reduce()` function | Use `UNWIND` + aggregation instead | +| `FOREACH` clause | Use `WITH` + `UNWIND` + `SET` instead | +| Regex match operator (`=~`) | Not supported in Neptune | + +### CALL Subqueries + +Supported with limitations: + +- Use `WITH` clause to import variables: `CALL { WITH var ... }` +- Updates inside CALL subqueries are NOT supported +- Emitted variables cannot overlap with variables before the CALL + +--- + +## Reference + +### pathfinding.cloud (Attack Path Definitions) + +- **Repository**: https://github.com/DataDog/pathfinding.cloud +- **All paths JSON**: `https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json` +- Use WebFetch to query specific paths or list available services + +### Cartography Schema + +- **URL pattern**: `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md` +- Always use the version from `api/pyproject.toml`, not master/main + +### openCypher Specification + +- **Neptune openCypher compliance** (what Neptune supports): https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html +- **Rewriting Cypher for Neptune** (converting Neo4j-specific syntax): https://docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html +- **openCypher project** (spec, grammar, TCK): https://github.com/opencypher/openCypher + +--- + +## Learning from the Queries Module + +**IMPORTANT**: Before creating a new query, ALWAYS read the entire queries module: + +``` +api/src/backend/api/attack_paths/queries/ +├── __init__.py # Module exports +├── types.py # Type definitions +├── registry.py # Registry logic +└── {provider}.py # Provider queries (aws.py, etc.) +``` + +Use the existing queries to learn: + +- Query structure and formatting +- Variable naming conventions +- How to include Prowler findings +- Comment style + +> **Compatibility Warning**: Some existing queries use Neo4j-specific features +> (e.g., `apoc.create.vNode`, `apoc.create.vRelationship`, regex `=~`) that are +> **NOT compatible** with Amazon Neptune. Use these queries to learn general +> patterns (structure, naming, Prowler findings integration, comment style) but +> **DO NOT copy APOC procedures or other Neo4j-specific syntax** into new queries. +> New queries must be pure openCypher Version 9. Refer to the +> [openCypher Compatibility](#opencypher-compatibility) section for the full list +> of features to avoid. + +**DO NOT** use generic templates. Match the exact style of existing **compatible** queries in the file. diff --git a/skills/prowler-changelog/SKILL.md b/skills/prowler-changelog/SKILL.md index 85a7fa4ba0..94f119e2ba 100644 --- a/skills/prowler-changelog/SKILL.md +++ b/skills/prowler-changelog/SKILL.md @@ -74,6 +74,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash - One entry per PR (can link multiple PRs for related changes) - No period at the end - Do NOT start with redundant verbs (section header already provides the action) +- **CRITICAL: Preserve section order** — when adding a new section to the UNRELEASED block, insert it in the correct position relative to existing sections (Added → Changed → Deprecated → Removed → Fixed → Security). Never append a new section at the top or bottom without checking order ### Semantic Versioning Rules @@ -177,6 +178,13 @@ This maintains chronological order within each section (oldest at top, newest at ### Bad Entries ```markdown +# BAD - Wrong section order (Fixed before Added) +### 🐞 Fixed +- Some bug fix [(#123)](...) + +### 🚀 Added +- Some new feature [(#456)](...) + - Fixed bug. # Too vague, has period - Added new feature for users # Missing PR link, redundant verb - Add search bar [(#123)] # Redundant verb (section already says "Added") diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index e005e3a05b..932bf2c766 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -245,19 +245,61 @@ class Test_Repository_Scoping: self.mock_repo2.get_branch.side_effect = Exception("404 Not Found") self.mock_repo2.get_dependabot_alerts.side_effect = Exception("404 Not Found") - def test_combined_repository_and_organization_scoping(self): - """Test that both repository and organization scoping can be used together""" + def test_qualified_repo_with_organization_skips_org_fetch(self): + """Test that a fully qualified repo with --organization does not fetch all org repos""" provider = set_mocked_github_provider() provider.repositories = ["owner1/repo1"] provider.organizations = ["org2"] mock_client = MagicMock() - # Repository lookup mock_client.get_repo.return_value = self.mock_repo1 - # Organization lookup - mock_org = MagicMock() - mock_org.get_repos.return_value = [self.mock_repo2] - mock_client.get_organization.return_value = mock_org + + with patch( + "prowler.providers.github.services.repository.repository_service.GithubService.__init__" + ): + repository_service = Repository(provider) + repository_service.clients = [mock_client] + repository_service.provider = provider + + repos = repository_service._list_repositories() + + assert len(repos) == 1 + assert 1 in repos + assert repos[1].name == "repo1" + mock_client.get_repo.assert_called_once_with("owner1/repo1") + mock_client.get_organization.assert_not_called() + + def test_unqualified_repo_qualified_with_organization(self): + """Test that an unqualified repo name is qualified with the organization""" + provider = set_mocked_github_provider() + provider.repositories = ["repo1"] + provider.organizations = ["owner1"] + + mock_client = MagicMock() + mock_client.get_repo.return_value = self.mock_repo1 + + with patch( + "prowler.providers.github.services.repository.repository_service.GithubService.__init__" + ): + repository_service = Repository(provider) + repository_service.clients = [mock_client] + repository_service.provider = provider + + repos = repository_service._list_repositories() + + assert len(repos) == 1 + assert 1 in repos + assert repos[1].name == "repo1" + mock_client.get_repo.assert_called_once_with("owner1/repo1") + + def test_unqualified_repo_qualified_with_multiple_organizations(self): + """Test that an unqualified repo is qualified with each organization""" + provider = set_mocked_github_provider() + provider.repositories = ["repo1"] + provider.organizations = ["org1", "org2"] + + mock_client = MagicMock() + mock_client.get_repo.side_effect = [self.mock_repo1, self.mock_repo2] with patch( "prowler.providers.github.services.repository.repository_service.GithubService.__init__" @@ -269,12 +311,56 @@ class Test_Repository_Scoping: repos = repository_service._list_repositories() assert len(repos) == 2 - assert 1 in repos - assert 2 in repos - assert repos[1].name == "repo1" - assert repos[2].name == "repo2" - mock_client.get_repo.assert_called_once_with("owner1/repo1") - mock_client.get_organization.assert_called_once_with("org2") + mock_client.get_repo.assert_any_call("org1/repo1") + mock_client.get_repo.assert_any_call("org2/repo1") + + def test_unqualified_repo_without_organization_is_skipped(self): + """Test that an unqualified repo without --organization is skipped with a warning""" + provider = set_mocked_github_provider() + provider.repositories = ["repo1"] + provider.organizations = [] + + mock_client = MagicMock() + + with patch( + "prowler.providers.github.services.repository.repository_service.GithubService.__init__" + ): + repository_service = Repository(provider) + repository_service.clients = [mock_client] + repository_service.provider = provider + + with patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger: + repos = repository_service._list_repositories() + + assert len(repos) == 0 + mock_logger.warning.assert_called_with( + "Repository name 'repo1' should be in 'owner/repo-name' format. Skipping." + ) + mock_client.get_repo.assert_not_called() + + def test_mixed_qualified_and_unqualified_repos_with_organization(self): + """Test mix of qualified and unqualified repos with --organization""" + provider = set_mocked_github_provider() + provider.repositories = ["repo1", "owner2/repo2"] + provider.organizations = ["org1"] + + mock_client = MagicMock() + mock_client.get_repo.side_effect = [self.mock_repo1, self.mock_repo2] + + with patch( + "prowler.providers.github.services.repository.repository_service.GithubService.__init__" + ): + repository_service = Repository(provider) + repository_service.clients = [mock_client] + repository_service.provider = provider + + repos = repository_service._list_repositories() + + assert len(repos) == 2 + mock_client.get_repo.assert_any_call("org1/repo1") + mock_client.get_repo.assert_any_call("owner2/repo2") class Test_Repository_Validation: diff --git a/tests/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured_test.py b/tests/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured_test.py new file mode 100644 index 0000000000..bbf11c03f2 --- /dev/null +++ b/tests/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured_test.py @@ -0,0 +1,303 @@ +from unittest import mock + +from prowler.providers.m365.services.defender.defender_service import ( + AdvancedThreatProtectionPolicy, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_defender_atp_safe_attachments_and_docs_configured: + """Tests for defender_atp_safe_attachments_and_docs_configured check.""" + + def test_no_atp_policy(self): + """Test when no ATP policy exists (advanced_threat_protection_policy is None).""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.advanced_threat_protection_policy = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured import ( + defender_atp_safe_attachments_and_docs_configured, + ) + + check = defender_atp_safe_attachments_and_docs_configured() + result = check.execute() + + assert len(result) == 0 + + def test_atp_policy_all_settings_compliant(self): + """Test when ATP policy is properly configured (PASS case).""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.advanced_threat_protection_policy = ( + AdvancedThreatProtectionPolicy( + identity="Default", + enable_atp_for_spo_teams_odb=True, + enable_safe_docs=True, + allow_safe_docs_open=False, + ) + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured import ( + defender_atp_safe_attachments_and_docs_configured, + ) + + check = defender_atp_safe_attachments_and_docs_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ATP policy Default has Safe Attachments for SharePoint, OneDrive, and Teams properly configured with Safe Documents enabled and click-through blocked." + ) + assert result[0].resource_id == "Default" + assert result[0].resource_name == "Default" + assert result[0].location == "global" + + def test_atp_policy_spo_teams_odb_disabled(self): + """Test when Safe Attachments for SPO/OneDrive/Teams is disabled (FAIL case).""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.advanced_threat_protection_policy = ( + AdvancedThreatProtectionPolicy( + identity="Default", + enable_atp_for_spo_teams_odb=False, + enable_safe_docs=True, + allow_safe_docs_open=False, + ) + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured import ( + defender_atp_safe_attachments_and_docs_configured, + ) + + check = defender_atp_safe_attachments_and_docs_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "ATP policy Default is not properly configured: Safe Attachments for SPO/OneDrive/Teams is disabled." + ) + assert result[0].resource_id == "Default" + assert result[0].resource_name == "Default" + assert result[0].location == "global" + + def test_atp_policy_safe_docs_disabled(self): + """Test when Safe Documents is disabled (FAIL case).""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.advanced_threat_protection_policy = ( + AdvancedThreatProtectionPolicy( + identity="Default", + enable_atp_for_spo_teams_odb=True, + enable_safe_docs=False, + allow_safe_docs_open=False, + ) + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured import ( + defender_atp_safe_attachments_and_docs_configured, + ) + + check = defender_atp_safe_attachments_and_docs_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "ATP policy Default is not properly configured: Safe Documents is disabled." + ) + assert result[0].resource_id == "Default" + assert result[0].resource_name == "Default" + assert result[0].location == "global" + + def test_atp_policy_safe_docs_open_allowed(self): + """Test when users can bypass Protected View for malicious files (FAIL case).""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.advanced_threat_protection_policy = ( + AdvancedThreatProtectionPolicy( + identity="Default", + enable_atp_for_spo_teams_odb=True, + enable_safe_docs=True, + allow_safe_docs_open=True, + ) + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured import ( + defender_atp_safe_attachments_and_docs_configured, + ) + + check = defender_atp_safe_attachments_and_docs_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "ATP policy Default is not properly configured: users can bypass Protected View for malicious files." + ) + assert result[0].resource_id == "Default" + assert result[0].resource_name == "Default" + assert result[0].location == "global" + + def test_atp_policy_all_settings_non_compliant(self): + """Test when all three settings are non-compliant (FAIL case).""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.advanced_threat_protection_policy = ( + AdvancedThreatProtectionPolicy( + identity="Default", + enable_atp_for_spo_teams_odb=False, + enable_safe_docs=False, + allow_safe_docs_open=True, + ) + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured import ( + defender_atp_safe_attachments_and_docs_configured, + ) + + check = defender_atp_safe_attachments_and_docs_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "ATP policy Default is not properly configured: Safe Attachments for SPO/OneDrive/Teams is disabled; Safe Documents is disabled; users can bypass Protected View for malicious files." + ) + assert result[0].resource_id == "Default" + assert result[0].resource_name == "Default" + assert result[0].location == "global" + + def test_atp_policy_custom_identity(self): + """Test with a custom policy identity name.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.advanced_threat_protection_policy = ( + AdvancedThreatProtectionPolicy( + identity="CustomPolicy", + enable_atp_for_spo_teams_odb=True, + enable_safe_docs=True, + allow_safe_docs_open=False, + ) + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_atp_safe_attachments_and_docs_configured.defender_atp_safe_attachments_and_docs_configured import ( + defender_atp_safe_attachments_and_docs_configured, + ) + + check = defender_atp_safe_attachments_and_docs_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ATP policy CustomPolicy has Safe Attachments for SharePoint, OneDrive, and Teams properly configured with Safe Documents enabled and click-through blocked." + ) + assert result[0].resource_id == "CustomPolicy" + assert result[0].resource_name == "CustomPolicy" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled_test.py b/tests/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled_test.py new file mode 100644 index 0000000000..b9bcc45ee1 --- /dev/null +++ b/tests/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled_test.py @@ -0,0 +1,468 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_defender_safe_attachments_policy_enabled: + """Tests for the defender_safe_attachments_policy_enabled check.""" + + def test_no_safe_attachments_policies(self): + """Test when no Safe Attachments policies exist.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + + defender_client.safe_attachments_policies = {} + defender_client.safe_attachments_rules = {} + + check = defender_safe_attachments_policy_enabled() + result = check.execute() + assert len(result) == 0 + + def test_case1_only_builtin_policy(self): + """Case 1: Only Built-in Protection Policy exists - always PASS.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ) + } + defender_client.safe_attachments_rules = {} + + check = defender_safe_attachments_policy_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is the only Safe Attachments policy" in result[0].status_extended + assert "baseline protection for all users" in result[0].status_extended + + def test_case2_builtin_and_custom_properly_configured(self): + """Case 2: Built-in + custom policy properly configured - both PASS.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Custom Policy": SafeAttachmentsPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + defender_client.safe_attachments_rules = { + "Custom Policy": SafeAttachmentsRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=["Engineering"], + domains=["example.com"], + ) + } + + check = defender_safe_attachments_policy_enabled() + result = check.execute() + + assert len(result) == 2 + + # Built-in policy PASS + builtin_result = next( + r for r in result if r.resource_name == "Built-In Protection Policy" + ) + assert builtin_result.status == "PASS" + assert ( + "provides baseline Safe Attachments protection" + in builtin_result.status_extended + ) + + # Custom policy PASS + custom_result = next( + r for r in result if r.resource_name == "Custom Policy" + ) + assert custom_result.status == "PASS" + assert "is properly configured" in custom_result.status_extended + assert "users: user@example.com" in custom_result.status_extended + assert "groups: Engineering" in custom_result.status_extended + assert "domains: example.com" in custom_result.status_extended + assert "priority 0" in custom_result.status_extended + + def test_case3_builtin_pass_custom_misconfigured(self): + """Case 3: Built-in PASS + custom policy misconfigured - Built-in PASS, custom FAIL.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Custom Policy": SafeAttachmentsPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable=False, # Misconfigured + action="Allow", # Misconfigured + quarantine_tag="DefaultFullAccessPolicy", # Misconfigured + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + defender_client.safe_attachments_rules = { + "Custom Policy": SafeAttachmentsRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safe_attachments_policy_enabled() + result = check.execute() + + assert len(result) == 2 + + # Built-in policy still PASS + builtin_result = next( + r for r in result if r.resource_name == "Built-In Protection Policy" + ) + assert builtin_result.status == "PASS" + + # Custom policy FAIL + custom_result = next( + r for r in result if r.resource_name == "Custom Policy" + ) + assert custom_result.status == "FAIL" + assert "is not properly configured" in custom_result.status_extended + assert "priority 0" in custom_result.status_extended + + def test_custom_policy_without_rule_skipped(self): + """Test that custom policies without associated rules are skipped.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Custom Policy Without Rule": SafeAttachmentsPolicy( + name="Custom Policy Without Rule", + identity="Custom-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + # Rule for a different policy + defender_client.safe_attachments_rules = { + "Other Policy": SafeAttachmentsRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safe_attachments_policy_enabled() + result = check.execute() + + # Only Built-in policy should be in results + assert len(result) == 1 + assert result[0].resource_name == "Built-In Protection Policy" + assert result[0].status == "PASS" + + def test_custom_policy_with_disabled_rule(self): + """Test when custom policy has proper settings but disabled rule (FAIL).""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Custom Policy": SafeAttachmentsPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + defender_client.safe_attachments_rules = { + "Custom Policy": SafeAttachmentsRule( + state="Disabled", # Disabled rule + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safe_attachments_policy_enabled() + result = check.execute() + + assert len(result) == 2 + + # Built-in policy PASS + builtin_result = next( + r for r in result if r.resource_name == "Built-In Protection Policy" + ) + assert builtin_result.status == "PASS" + + # Custom policy FAIL because rule is disabled + custom_result = next( + r for r in result if r.resource_name == "Custom Policy" + ) + assert custom_result.status == "FAIL" + assert "is not properly configured" in custom_result.status_extended + + def test_custom_policy_applies_to_all_users_when_no_scope(self): + """Test that custom policy with no users/groups/domains shows 'all users'.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Houston Safe Attachments Policy test": SafeAttachmentsPolicy( + name="Houston Safe Attachments Policy test", + identity="Houston-Policy-ID", + enable=False, # Misconfigured + action="Allow", + quarantine_tag="DefaultFullAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + defender_client.safe_attachments_rules = { + "Houston Safe Attachments Policy test": SafeAttachmentsRule( + state="Enabled", + priority=0, + users=None, # No users specified + groups=None, # No groups specified + domains=None, # No domains specified - applies to ALL users + ) + } + + check = defender_safe_attachments_policy_enabled() + result = check.execute() + + assert len(result) == 2 + + # Custom policy should show "all users" in status_extended + custom_result = next( + r + for r in result + if r.resource_name == "Houston Safe Attachments Policy test" + ) + assert custom_result.status == "FAIL" + assert "is not properly configured" in custom_result.status_extended + assert "all users" in custom_result.status_extended + assert "priority 0" in custom_result.status_extended diff --git a/tests/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled_test.py b/tests/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled_test.py new file mode 100644 index 0000000000..6982d1788b --- /dev/null +++ b/tests/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled_test.py @@ -0,0 +1,521 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_defender_safelinks_policy_enabled: + """Tests for the defender_safelinks_policy_enabled check.""" + + def test_no_safe_links_policies(self): + """Test when no Safe Links policies exist.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import ( + defender_safelinks_policy_enabled, + ) + + defender_client.safe_links_policies = {} + defender_client.safe_links_rules = {} + + check = defender_safelinks_policy_enabled() + result = check.execute() + assert len(result) == 0 + + def test_case1_only_builtin_policy(self): + """Case 1: Only Built-in Protection Policy exists - always PASS.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import ( + defender_safelinks_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeLinksPolicy, + ) + + defender_client.safe_links_policies = { + "Built-In Protection Policy": SafeLinksPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=True, + is_default=False, + ) + } + defender_client.safe_links_rules = {} + + check = defender_safelinks_policy_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is the only Safe Links policy" in result[0].status_extended + assert "baseline protection for all users" in result[0].status_extended + + def test_case2_builtin_and_custom_properly_configured(self): + """Case 2: Built-in + custom policy properly configured - both PASS.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import ( + defender_safelinks_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeLinksPolicy, + SafeLinksRule, + ) + + defender_client.safe_links_policies = { + "Built-In Protection Policy": SafeLinksPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=True, + is_default=False, + ), + "Custom Policy": SafeLinksPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=False, + is_default=False, + ), + } + defender_client.safe_links_rules = { + "Custom Policy": SafeLinksRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=["Engineering"], + domains=["example.com"], + ) + } + + check = defender_safelinks_policy_enabled() + result = check.execute() + + assert len(result) == 2 + + # Built-in policy PASS + builtin_result = next( + r for r in result if r.resource_name == "Built-In Protection Policy" + ) + assert builtin_result.status == "PASS" + assert ( + "provides baseline Safe Links protection" + in builtin_result.status_extended + ) + + # Custom policy PASS + custom_result = next( + r for r in result if r.resource_name == "Custom Policy" + ) + assert custom_result.status == "PASS" + assert "is properly configured" in custom_result.status_extended + assert "users: user@example.com" in custom_result.status_extended + assert "groups: Engineering" in custom_result.status_extended + assert "domains: example.com" in custom_result.status_extended + assert "priority 0" in custom_result.status_extended + + def test_case3_builtin_pass_custom_misconfigured(self): + """Case 3: Built-in PASS + custom policy misconfigured - Built-in PASS, custom FAIL.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import ( + defender_safelinks_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeLinksPolicy, + SafeLinksRule, + ) + + defender_client.safe_links_policies = { + "Built-In Protection Policy": SafeLinksPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=True, + is_default=False, + ), + "Custom Policy": SafeLinksPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable_safe_links_for_email=False, # Misconfigured + enable_safe_links_for_teams=False, # Misconfigured + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=True, # Misconfigured + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=False, + is_default=False, + ), + } + defender_client.safe_links_rules = { + "Custom Policy": SafeLinksRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safelinks_policy_enabled() + result = check.execute() + + assert len(result) == 2 + + # Built-in policy still PASS + builtin_result = next( + r for r in result if r.resource_name == "Built-In Protection Policy" + ) + assert builtin_result.status == "PASS" + + # Custom policy FAIL + custom_result = next( + r for r in result if r.resource_name == "Custom Policy" + ) + assert custom_result.status == "FAIL" + assert "is not properly configured" in custom_result.status_extended + assert "priority 0" in custom_result.status_extended + + def test_custom_policy_without_rule_skipped(self): + """Test that custom policies without associated rules are skipped.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import ( + defender_safelinks_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeLinksPolicy, + SafeLinksRule, + ) + + defender_client.safe_links_policies = { + "Built-In Protection Policy": SafeLinksPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=True, + is_default=False, + ), + "Custom Policy Without Rule": SafeLinksPolicy( + name="Custom Policy Without Rule", + identity="Custom-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=False, + is_default=False, + ), + } + # Rule for a different policy + defender_client.safe_links_rules = { + "Other Policy": SafeLinksRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safelinks_policy_enabled() + result = check.execute() + + # Only Built-in policy should be in results + assert len(result) == 1 + assert result[0].resource_name == "Built-In Protection Policy" + assert result[0].status == "PASS" + + def test_custom_policy_with_disabled_rule(self): + """Test when custom policy has proper settings but disabled rule (FAIL).""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import ( + defender_safelinks_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeLinksPolicy, + SafeLinksRule, + ) + + defender_client.safe_links_policies = { + "Built-In Protection Policy": SafeLinksPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=True, + is_default=False, + ), + "Custom Policy": SafeLinksPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=False, + is_default=False, + ), + } + defender_client.safe_links_rules = { + "Custom Policy": SafeLinksRule( + state="Disabled", # Disabled rule + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safelinks_policy_enabled() + result = check.execute() + + assert len(result) == 2 + + # Built-in policy PASS + builtin_result = next( + r for r in result if r.resource_name == "Built-In Protection Policy" + ) + assert builtin_result.status == "PASS" + + # Custom policy FAIL because rule is disabled + custom_result = next( + r for r in result if r.resource_name == "Custom Policy" + ) + assert custom_result.status == "FAIL" + assert "is not properly configured" in custom_result.status_extended + + def test_custom_policy_applies_to_all_users_when_no_scope(self): + """Test that custom policy with no users/groups/domains shows 'all users'.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import ( + defender_safelinks_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeLinksPolicy, + SafeLinksRule, + ) + + defender_client.safe_links_policies = { + "Built-In Protection Policy": SafeLinksPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable_safe_links_for_email=True, + enable_safe_links_for_teams=True, + enable_safe_links_for_office=True, + track_clicks=True, + allow_click_through=False, + scan_urls=True, + enable_for_internal_senders=True, + deliver_message_after_scan=True, + disable_url_rewrite=False, + is_built_in_protection=True, + is_default=False, + ), + "Houston Safe Links Policy test": SafeLinksPolicy( + name="Houston Safe Links Policy test", + identity="Houston-Policy-ID", + enable_safe_links_for_email=False, # Misconfigured + enable_safe_links_for_teams=False, + enable_safe_links_for_office=False, + track_clicks=False, + allow_click_through=True, + scan_urls=False, + enable_for_internal_senders=False, + deliver_message_after_scan=False, + disable_url_rewrite=True, + is_built_in_protection=False, + is_default=False, + ), + } + defender_client.safe_links_rules = { + "Houston Safe Links Policy test": SafeLinksRule( + state="Enabled", + priority=0, + users=None, # No users specified + groups=None, # No groups specified + domains=None, # No domains specified - applies to ALL users + ) + } + + check = defender_safelinks_policy_enabled() + result = check.execute() + + assert len(result) == 2 + + # Custom policy should show "all users" in status_extended + custom_result = next( + r for r in result if r.resource_name == "Houston Safe Links Policy test" + ) + assert custom_result.status == "FAIL" + assert "is not properly configured" in custom_result.status_extended + assert "all users" in custom_result.status_extended + assert "priority 0" in custom_result.status_extended diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index cd8d6a6f35..069e8b40ec 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,36 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.18.0] (Prowler UNRELEASED) +## [1.19.0] (Prowler UNRELEASED) + +### 🔄 Changed + +- Attack Paths: Query list now shows their name and short description, when one is selected it also shows a longer description and an attribution if it has it [(#9983)](https://github.com/prowler-cloud/prowler/pull/9983) + +--- + +## [1.18.2] (Prowler UNRELEASED) + +### 🐞 Fixed + +- ProviderTypeSelector crashing when an unknown provider type is missing from PROVIDER_DATA [(#9991)](https://github.com/prowler-cloud/prowler/pull/9991) +- Infinite memory loop when opening modals from table row action dropdowns due to HeroUI and Radix Dialog overlay conflict [(#9996)](https://github.com/prowler-cloud/prowler/pull/9996) +- Filter changes not coordinating with Suspense boundaries in ProviderTypeSelector, AccountsSelector, and muted findings checkbox [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013) +- Scans page pagination not refreshing table data after page change [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013) +- Duplicate `filter[search]` parameter in findings and scans API calls [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013) +- Filters on `/findings` silently reverting on first click in production [(#10034)](https://github.com/prowler-cloud/prowler/pull/10034) + +--- + +## [1.18.1] (Prowler v5.18.1) + +### 🐞 Fixed + +- Scans page polling now only refreshes scan table data instead of re-rendering the entire server component tree, eliminating redundant API calls to providers, findings, and compliance endpoints every 5 seconds + +--- + +## [1.18.0] (Prowler v5.18.0) ### 🔄 Changed diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 1fade543aa..d226795483 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -25,7 +25,10 @@ export const getFindings = async ({ if (sort) url.searchParams.append("sort", sort); Object.entries(filters).forEach(([key, value]) => { - url.searchParams.append(key, String(value)); + // Skip filter[search] since it's already added via the `query` param above + if (key !== "filter[search]") { + url.searchParams.append(key, String(value)); + } }); try { @@ -63,7 +66,10 @@ export const getLatestFindings = async ({ if (sort) url.searchParams.append("sort", sort); Object.entries(filters).forEach(([key, value]) => { - url.searchParams.append(key, String(value)); + // Skip filter[search] since it's already added via the `query` param above + if (key !== "filter[search]") { + url.searchParams.append(key, String(value)); + } }); try { diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index ccbd2448c0..a5f615bfe4 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -37,7 +37,10 @@ export const getScans = async ({ // Add dynamic filters (e.g., "filter[state]", "fields[scans]") Object.entries(filters).forEach(([key, value]) => { - url.searchParams.append(key, String(value)); + // Skip filter[search] since it's already added via the `query` param above + if (key !== "filter[search]") { + url.searchParams.append(key, String(value)); + } }); try { diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index dce5f049dc..98bb9df9f5 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { ReactNode } from "react"; import { @@ -22,6 +22,7 @@ import { MultiSelectTrigger, MultiSelectValue, } from "@/components/shadcn/select/multiselect"; +import { useUrlFilters } from "@/hooks/use-url-filters"; import type { ProviderProps, ProviderType } from "@/types/providers"; const PROVIDER_ICON: Record = { @@ -42,8 +43,8 @@ interface AccountsSelectorProps { } export function AccountsSelector({ providers }: AccountsSelectorProps) { - const router = useRouter(); const searchParams = useSearchParams(); + const { navigateWithParams } = useUrlFilters(); const filterKey = "filter[provider_id__in]"; const current = searchParams.get(filterKey) || ""; @@ -61,38 +62,37 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) { ); const handleMultiValueChange = (ids: string[]) => { - const params = new URLSearchParams(searchParams.toString()); - params.delete(filterKey); + navigateWithParams((params) => { + params.delete(filterKey); - if (ids.length > 0) { - params.set(filterKey, ids.join(",")); - } - - // Auto-deselect provider types that no longer have any selected accounts - if (selectedTypesList.length > 0) { - // Get provider types of currently selected accounts - const selectedProviders = providers.filter((p) => ids.includes(p.id)); - const selectedProviderTypes = new Set( - selectedProviders.map((p) => p.attributes.provider), - ); - - // Keep only provider types that still have selected accounts - const remainingProviderTypes = selectedTypesList.filter((type) => - selectedProviderTypes.has(type as ProviderType), - ); - - // Update provider_type__in filter - if (remainingProviderTypes.length > 0) { - params.set( - "filter[provider_type__in]", - remainingProviderTypes.join(","), - ); - } else { - params.delete("filter[provider_type__in]"); + if (ids.length > 0) { + params.set(filterKey, ids.join(",")); } - } - router.push(`?${params.toString()}`, { scroll: false }); + // Auto-deselect provider types that no longer have any selected accounts + if (selectedTypesList.length > 0) { + // Get provider types of currently selected accounts + const selectedProviders = providers.filter((p) => ids.includes(p.id)); + const selectedProviderTypes = new Set( + selectedProviders.map((p) => p.attributes.provider), + ); + + // Keep only provider types that still have selected accounts + const remainingProviderTypes = selectedTypesList.filter((type) => + selectedProviderTypes.has(type as ProviderType), + ); + + // Update provider_type__in filter + if (remainingProviderTypes.length > 0) { + params.set( + "filter[provider_type__in]", + remainingProviderTypes.join(","), + ); + } else { + params.delete("filter[provider_type__in]"); + } + } + }); }; const selectedLabel = () => { diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index e5eb7928f3..d9457a499b 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { lazy, Suspense } from "react"; import { @@ -10,6 +10,7 @@ import { MultiSelectTrigger, MultiSelectValue, } from "@/components/shadcn/select/multiselect"; +import { useUrlFilters } from "@/hooks/use-url-filters"; import { type ProviderProps, ProviderType } from "@/types/providers"; const AWSProviderBadge = lazy(() => @@ -122,8 +123,8 @@ type ProviderTypeSelectorProps = { export const ProviderTypeSelector = ({ providers, }: ProviderTypeSelectorProps) => { - const router = useRouter(); const searchParams = useSearchParams(); + const { navigateWithParams } = useUrlFilters(); const currentProviders = searchParams.get("filter[provider_type__in]") || ""; const selectedTypes = currentProviders @@ -131,20 +132,18 @@ export const ProviderTypeSelector = ({ : []; const handleMultiValueChange = (values: string[]) => { - const params = new URLSearchParams(searchParams.toString()); + navigateWithParams((params) => { + // Update provider_type__in + if (values.length > 0) { + params.set("filter[provider_type__in]", values.join(",")); + } else { + params.delete("filter[provider_type__in]"); + } - // Update provider_type__in - if (values.length > 0) { - params.set("filter[provider_type__in]", values.join(",")); - } else { - params.delete("filter[provider_type__in]"); - } - - // Clear account selection when changing provider types - // User should manually select accounts if they want to filter by specific accounts - params.delete("filter[provider_id__in]"); - - router.push(`?${params.toString()}`, { scroll: false }); + // Clear account selection when changing provider types + // User should manually select accounts if they want to filter by specific accounts + params.delete("filter[provider_id__in]"); + }); }; const availableTypes = Array.from( @@ -153,7 +152,7 @@ export const ProviderTypeSelector = ({ // .filter((p) => p.attributes.connection?.connected) .map((p) => p.attributes.provider), ), - ) as ProviderType[]; + ).filter((type): type is ProviderType => type in PROVIDER_DATA); const renderIcon = (providerType: ProviderType) => { const IconComponent = PROVIDER_DATA[providerType].icon; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx index 65acb8a84a..f7d36c9a68 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx @@ -35,7 +35,7 @@ export const QuerySelector = ({
{query.attributes.name} - {query.attributes.description} + {query.attributes.short_description}
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx index a817b9a715..ff66a7e68f 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx @@ -340,6 +340,33 @@ export default function AttackPathAnalysisPage() { onQueryChange={handleQueryChange} /> + {queryBuilder.selectedQueryData && ( +
+

+ {queryBuilder.selectedQueryData.attributes.description} +

+ {queryBuilder.selectedQueryData.attributes.attribution && ( +

+ Source:{" "} + + { + queryBuilder.selectedQueryData.attributes + .attribution.text + } + +

+ )} +
+ )} + {queryBuilder.selectedQuery && ( [] = [ + { + accessorKey: "name", + size: 300, + header: ({ table }) => ( +
+ + Name +
+ ), + cell: ({ row }) => { + const Icon = TYPE_ICONS[row.original.type]; + return ( + +
+ + {row.original.name} +
+
+ ); + }, + }, + { + accessorKey: "type", + size: 120, + header: "Type", + cell: ({ row }) => {row.original.type}, + }, + { + accessorKey: "status", + size: 120, + header: "Status", + cell: ({ row }) => ( + + {row.original.status} + + ), + }, + { + accessorKey: "resourceCount", + size: 100, + header: "Resources", + cell: ({ row }) => row.original.resourceCount.toLocaleString(), + }, +]; + +export default function DemoExpandableTablePage() { + if (!IS_DEV) { + notFound(); + } + + return ( +
+

Expandable DataTable Demo

+ + {/* Expandable DataTable */} +
+
+

Expandable DataTable

+

+ Table with hierarchical rows. Click chevron to expand/collapse, or + use the header icon to expand/collapse all. +

+
+ + row.children} + defaultExpanded={true} + /> +
+ + {/* Expandable DataTable with Row Selection */} +
+
+

+ Expandable DataTable with Row Selection +

+

+ Selecting a parent auto-selects all children + (enableSubRowSelection). +

+
+ + row.children} + enableRowSelection + enableSubRowSelection + defaultExpanded={{ "org-1": true, "ou-prod": true }} + /> +
+
+ ); +} diff --git a/ui/app/(prowler)/demo-tree-view/page.tsx b/ui/app/(prowler)/demo-tree-view/page.tsx new file mode 100644 index 0000000000..353aeccc57 --- /dev/null +++ b/ui/app/(prowler)/demo-tree-view/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react"; +import { notFound } from "next/navigation"; +import { useState } from "react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { TreeView } from "@/components/shadcn/tree-view"; +import { TreeDataItem } from "@/types/tree"; + +/** + * Demo page for the TreeView component. + * + * ⚠️ DEVELOPMENT ONLY - This page is for component demonstration and testing. + * It returns 404 in production environments. + * + * Showcases: + * 1. TreeView with checkboxes and hierarchical selection + * 2. TreeView without checkboxes (navigation mode) + * 3. Custom rendering with renderItem prop + */ + +// Hide in production - evaluated at build time +const IS_DEV = process.env.NODE_ENV === "development"; + +const accountsTreeData: TreeDataItem[] = [ + { + id: "org-1", + name: "Organization Root", + icon: ServerIcon, + children: [ + { + id: "ou-1", + name: "ou-996789098 (Production)", + icon: FolderIcon, + status: "success", + children: [ + { + id: "acc-1", + name: "123456789098", + icon: CloudIcon, + status: "success", + }, + { + id: "acc-2", + name: "123456789099", + icon: CloudIcon, + status: "success", + }, + { + id: "acc-3", + name: "123456789100", + icon: CloudIcon, + status: "success", + }, + ], + }, + { + id: "ou-2", + name: "ou-996789099 (Development)", + icon: FolderIcon, + status: "error", + children: [ + { + id: "acc-4", + name: "223456789098", + icon: CloudIcon, + status: "success", + }, + { + id: "acc-5", + name: "223456789099", + icon: CloudIcon, + status: "error", + }, + ], + }, + { + id: "ou-3", + name: "ou-996789100 (Staging)", + icon: FolderIcon, + isLoading: true, + children: [{ id: "acc-6", name: "323456789098", icon: CloudIcon }], + }, + ], + }, +]; + +export default function DemoTreeViewPage() { + // Return 404 in production - this page is for development only + if (!IS_DEV) { + notFound(); + } + + const [selectedIds, setSelectedIds] = useState([]); + const [expandedIds, setExpandedIds] = useState(["org-1"]); + + return ( +
+

TreeView Component Demo

+ + {/* TreeView with Checkboxes */} +
+
+

+ TreeView with Checkboxes (Account Selector) +

+

+ Select accounts hierarchically. Selecting a parent selects all + children. +

+
+ +
+
+ ( +
+ {item.icon && } + + + {item.name} + + {item.name} + + {hasChildren && !isLeaf && ( + + {item.children?.length} + + )} +
+ )} + /> +
+ +
+

Selected IDs:

+
+              {JSON.stringify(selectedIds, null, 2)}
+            
+
+
+
+ + {/* TreeView without Checkboxes */} +
+
+

+ TreeView without Checkboxes (Navigation) +

+

+ Click to expand/collapse. Use arrow keys to navigate. +

+
+ +
+ +
+
+
+ ); +} diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx index f57b9ffbe4..70f437d967 100644 --- a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx +++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx @@ -1,17 +1,15 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; import { Pencil, Trash2 } from "lucide-react"; import { MuteRuleData } from "@/actions/mute-rules/types"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownDangerZone, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; interface MuteRuleRowActionsProps { muteRule: MuteRuleData; @@ -26,11 +24,8 @@ export function MuteRuleRowActions({ }: MuteRuleRowActionsProps) { return (
- - + - - - - - } - onPress={() => onEdit(muteRule)} - > - Edit - - - } - onPress={() => onDelete(muteRule)} - > - Delete - - - - + } + > + } + label="Edit Mute Rule" + onSelect={() => onEdit(muteRule)} + /> + + } + label="Delete Mute Rule" + destructive + onSelect={() => onDelete(muteRule)} + /> + +
); } diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 6e52449c04..5cc1582364 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -13,7 +13,7 @@ import { } from "@/components/providers/table"; import { ContentLayout } from "@/components/ui"; import { DataTable } from "@/components/ui/table"; -import { ProviderProps, SearchParamsProps } from "@/types"; +import { PROVIDER_TYPES, ProviderProps, SearchParamsProps } from "@/types"; export default async function Providers({ searchParams, @@ -89,15 +89,22 @@ const ProvidersTable = async ({ return acc; }, {}) || {}; + // Exclude provider types not yet supported in the UI const enrichedProviders = - providersData?.data?.map((provider: ProviderProps) => { - const groupNames = - provider.relationships?.provider_groups?.data?.map( - (group: { id: string }) => - providerGroupDict[group.id] || "Unknown Group", - ) || []; - return { ...provider, groupNames }; - }) || []; + providersData?.data + ?.filter((provider: ProviderProps) => + (PROVIDER_TYPES as readonly string[]).includes( + provider.attributes.provider, + ), + ) + .map((provider: ProviderProps) => { + const groupNames = + provider.relationships?.provider_groups?.data?.map( + (group: { id: string }) => + providerGroupDict[group.id] || "Unknown Group", + ) || []; + return { ...provider, groupNames }; + }) || []; return ( <> diff --git a/ui/components/filters/custom-checkbox-muted-findings.tsx b/ui/components/filters/custom-checkbox-muted-findings.tsx index 9af7722957..546ebed5a8 100644 --- a/ui/components/filters/custom-checkbox-muted-findings.tsx +++ b/ui/components/filters/custom-checkbox-muted-findings.tsx @@ -1,8 +1,9 @@ "use client"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { Checkbox } from "@/components/shadcn"; +import { useUrlFilters } from "@/hooks/use-url-filters"; // Constants for muted filter URL values const MUTED_FILTER_VALUES = { @@ -11,12 +12,10 @@ const MUTED_FILTER_VALUES = { } as const; export const CustomCheckboxMutedFindings = () => { - const router = useRouter(); - const pathname = usePathname(); const searchParams = useSearchParams(); + const { navigateWithParams } = useUrlFilters(); // Get the current muted filter value from URL - // Middleware ensures filter[muted] is always present when navigating to /findings const mutedFilterValue = searchParams.get("filter[muted]"); // URL states: @@ -26,22 +25,16 @@ export const CustomCheckboxMutedFindings = () => { const handleMutedChange = (checked: boolean | "indeterminate") => { const isChecked = checked === true; - const params = new URLSearchParams(searchParams.toString()); - if (isChecked) { - // Include muted: set special value (API will ignore invalid value and show all) - params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE); - } else { - // Exclude muted: apply filter to show only non-muted - params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE); - } - - // Reset to page 1 when changing filter - if (params.has("page")) { - params.set("page", "1"); - } - - router.push(`${pathname}?${params.toString()}`, { scroll: false }); + navigateWithParams((params) => { + if (isChecked) { + // Include muted: set special value (API will ignore invalid value and show all) + params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE); + } else { + // Exclude muted: apply filter to show only non-muted + params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE); + } + }); }; return ( diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index 9055d7eb9a..752f9f5144 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -66,30 +66,13 @@ export function DataTableRowActions({ return [finding.id]; }; - const getMuteDescription = (): string => { - if (isMuted) { - return "This finding is already muted"; - } - const ids = getMuteIds(); - if (ids.length > 1) { - return `Mute ${ids.length} selected findings`; - } - return "Mute this finding"; - }; - const getMuteLabel = () => { if (isMuted) return "Muted"; - if (!isMuted && isCurrentSelected && hasMultipleSelected) { - return ( - <> - Mute - - ({selectedFindingIds.length}) - - - ); + const ids = getMuteIds(); + if (ids.length > 1) { + return `Mute ${ids.length} Findings`; } - return "Mute"; + return "Mute Finding"; }; const handleMuteComplete = () => { @@ -146,7 +129,6 @@ export function DataTableRowActions({ ) } label={getMuteLabel()} - description={getMuteDescription()} disabled={isMuted} onSelect={() => { setIsMuteModalOpen(true); @@ -155,7 +137,6 @@ export function DataTableRowActions({ } label="Send to Jira" - description="Create a Jira issue for this finding" onSelect={() => setIsJiraModalOpen(true)} /> diff --git a/ui/components/invitations/table/data-table-row-actions.tsx b/ui/components/invitations/table/data-table-row-actions.tsx index 4ec5f50e79..b643202f80 100644 --- a/ui/components/invitations/table/data-table-row-actions.tsx +++ b/ui/components/invitations/table/data-table-row-actions.tsx @@ -1,24 +1,17 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; -import { - AddNoteBulkIcon, - DeleteDocumentBulkIcon, - EditDocumentBulkIcon, -} from "@heroui/shared-icons"; import { Row } from "@tanstack/react-table"; -import clsx from "clsx"; +import { Eye, Pencil, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownDangerZone, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; import { DeleteForm, EditForm } from "../forms"; @@ -27,7 +20,6 @@ interface DataTableRowActionsProps { row: Row; roles?: { id: string; name: string }[]; } -const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0"; export function DataTableRowActions({ row, @@ -67,65 +59,36 @@ export function DataTableRowActions({
- - + - - - - } - onPress={() => - router.push(`/invitations/check-details?id=${invitationId}`) - } - > - Check Details - - - } - onPress={() => setIsEditOpen(true)} - isDisabled={invitationAccepted === "accepted"} - > - Edit Invitation - - - - - } - onPress={() => setIsDeleteOpen(true)} - isDisabled={invitationAccepted === "accepted"} - > - Revoke Invitation - - - - + } + > + } + label="Check Details" + onSelect={() => + router.push(`/invitations/check-details?id=${invitationId}`) + } + /> + } + label="Edit Invitation" + onSelect={() => setIsEditOpen(true)} + disabled={invitationAccepted === "accepted"} + /> + + } + label="Revoke Invitation" + destructive + onSelect={() => setIsDeleteOpen(true)} + disabled={invitationAccepted === "accepted"} + /> + +
); diff --git a/ui/components/manage-groups/table/data-table-row-actions.tsx b/ui/components/manage-groups/table/data-table-row-actions.tsx index 3ea2f9c9a1..519b0b05e2 100644 --- a/ui/components/manage-groups/table/data-table-row-actions.tsx +++ b/ui/components/manage-groups/table/data-table-row-actions.tsx @@ -1,23 +1,17 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; -import { - DeleteDocumentBulkIcon, - EditDocumentBulkIcon, -} from "@heroui/shared-icons"; import { Row } from "@tanstack/react-table"; -import clsx from "clsx"; +import { Pencil, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownDangerZone, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; import { DeleteGroupForm } from "../forms"; @@ -25,7 +19,6 @@ import { DeleteGroupForm } from "../forms"; interface DataTableRowActionsProps { row: Row; } -const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0"; export function DataTableRowActions({ row, @@ -47,51 +40,27 @@ export function DataTableRowActions({
- - + - - - - } - onPress={() => router.push(`/manage-groups?groupId=${groupId}`)} - > - Edit Provider Group - - - - - } - onPress={() => setIsDeleteOpen(true)} - > - Delete Provider Group - - - - + } + > + } + label="Edit Provider Group" + onSelect={() => router.push(`/manage-groups?groupId=${groupId}`)} + /> + + } + label="Delete Provider Group" + destructive + onSelect={() => setIsDeleteOpen(true)} + /> + +
); diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 1866abe8ab..023f563006 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -1,25 +1,18 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; -import { - AddNoteBulkIcon, - DeleteDocumentBulkIcon, - EditDocumentBulkIcon, -} from "@heroui/shared-icons"; import { Row } from "@tanstack/react-table"; -import clsx from "clsx"; +import { Pencil, PlugZap, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { checkConnectionProvider } from "@/actions/providers/providers"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownDangerZone, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; import { EditForm } from "../forms"; @@ -28,7 +21,6 @@ import { DeleteForm } from "../forms/delete-form"; interface DataTableRowActionsProps { row: Row; } -const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0"; export function DataTableRowActions({ row, @@ -53,12 +45,6 @@ export function DataTableRowActions({ const hasSecret = (row.original as any).relationships?.secret?.data; - // Calculate disabled keys based on conditions - const disabledKeys = []; - if (!hasSecret || loading) { - disabledKeys.push("new"); - } - return ( <> ({
- - + - - - - } - onPress={() => - router.push( - `/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`, - ) - } - closeOnSelect={true} - > - {hasSecret ? "Update Credentials" : "Add Credentials"} - - } - onPress={handleTestConnection} - closeOnSelect={false} - > - {loading ? "Testing..." : "Test Connection"} - - } - onPress={() => setIsEditOpen(true)} - closeOnSelect={true} - > - Edit Provider Alias - - - - - } - onPress={() => setIsDeleteOpen(true)} - closeOnSelect={true} - > - Delete Provider - - - - + } + > + } + label={hasSecret ? "Update Credentials" : "Add Credentials"} + onSelect={() => + router.push( + `/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`, + ) + } + /> + } + label={loading ? "Testing..." : "Test Connection"} + description={ + hasSecret && !loading + ? "Check the provider connection" + : loading + ? "Checking provider connection" + : "Add credentials to test the connection" + } + onSelect={(e) => { + e.preventDefault(); + handleTestConnection(); + }} + disabled={!hasSecret || loading} + /> + } + label="Edit Provider Alias" + onSelect={() => setIsEditOpen(true)} + /> + + } + label="Delete Provider" + destructive + onSelect={() => setIsDeleteOpen(true)} + /> + +
); diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index e94021d2d0..cdbc000ff3 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -83,7 +83,6 @@ const FailedFindingsBadge = ({ count }: { count: number }) => { // Row actions dropdown const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => { const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const resourceName = row.original.attributes?.name || "Resource"; return ( <> @@ -102,8 +101,7 @@ const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => { > } - label="View details" - description={`View details for ${resourceName}`} + label="View Details" onSelect={() => setIsDrawerOpen(true)} /> diff --git a/ui/components/roles/table/data-table-row-actions.tsx b/ui/components/roles/table/data-table-row-actions.tsx index 0eb7864a51..70895817e3 100644 --- a/ui/components/roles/table/data-table-row-actions.tsx +++ b/ui/components/roles/table/data-table-row-actions.tsx @@ -1,30 +1,23 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; -import { - DeleteDocumentBulkIcon, - EditDocumentBulkIcon, -} from "@heroui/shared-icons"; import { Row } from "@tanstack/react-table"; -import clsx from "clsx"; +import { Pencil, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownDangerZone, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; import { DeleteRoleForm } from "../workflow/forms"; interface DataTableRowActionsProps { row: Row; } -const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0"; export function DataTableRowActions({ row, @@ -43,51 +36,27 @@ export function DataTableRowActions({
- - + - - - - } - onPress={() => router.push(`/roles/edit?roleId=${roleId}`)} - > - Edit Role - - - - - } - onPress={() => setIsDeleteOpen(true)} - > - Delete Role - - - - + } + > + } + label="Edit Role" + onSelect={() => router.push(`/roles/edit?roleId=${roleId}`)} + /> + + } + label="Delete Role" + destructive + onSelect={() => setIsDeleteOpen(true)} + /> + +
); diff --git a/ui/components/scans/table/scans/data-table-row-actions.tsx b/ui/components/scans/table/scans/data-table-row-actions.tsx index b9d16db9ab..e185d0d43d 100644 --- a/ui/components/scans/table/scans/data-table-row-actions.tsx +++ b/ui/components/scans/table/scans/data-table-row-actions.tsx @@ -1,22 +1,15 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; -import { - // DeleteDocumentBulkIcon, - EditDocumentBulkIcon, -} from "@heroui/shared-icons"; import { Row } from "@tanstack/react-table"; -import { DownloadIcon } from "lucide-react"; +import { Download, Pencil } from "lucide-react"; import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; import { downloadScanZip } from "@/lib/helper"; @@ -26,7 +19,6 @@ import { EditScanForm } from "../../forms"; interface DataTableRowActionsProps { row: Row; } -const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0"; export function DataTableRowActions({ row, @@ -52,46 +44,26 @@ export function DataTableRowActions({
- - + - - - - } - onPress={() => downloadScanZip(scanId, toast)} - isDisabled={scanState !== "completed"} - > - Download .zip - - - - } - onPress={() => setIsEditOpen(true)} - > - Edit scan name - - - - + } + > + } + label="Download .zip" + description="Available only for completed scans" + onSelect={() => downloadScanZip(scanId, toast)} + disabled={scanState !== "completed"} + /> + } + label="Edit Scan Name" + onSelect={() => setIsEditOpen(true)} + /> +
); diff --git a/ui/components/scans/table/scans/scans-table-with-polling.tsx b/ui/components/scans/table/scans/scans-table-with-polling.tsx new file mode 100644 index 0000000000..3854871941 --- /dev/null +++ b/ui/components/scans/table/scans/scans-table-with-polling.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { getScans } from "@/actions/scans"; +import { AutoRefresh } from "@/components/scans"; +import { DataTable } from "@/components/ui/table"; +import { MetaDataProps, ScanProps, SearchParamsProps } from "@/types"; + +import { ColumnGetScans } from "./column-get-scans"; + +export const SCAN_LAUNCHED_EVENT = "scan-launched"; + +interface ScansTableWithPollingProps { + initialData: ScanProps[]; + initialMeta?: MetaDataProps; + searchParams: SearchParamsProps; +} + +const EXECUTING_STATES = ["executing", "available"] as const; + +function expandScansWithProviderInfo( + scans: ScanProps[], + included?: Array<{ type: string; id: string; attributes: any }>, +) { + return ( + scans?.map((scan) => { + const providerId = scan.relationships?.provider?.data?.id; + + if (!providerId) { + return { ...scan, providerInfo: undefined }; + } + + const providerData = included?.find( + (item) => item.type === "providers" && item.id === providerId, + ); + + if (!providerData) { + return { ...scan, providerInfo: undefined }; + } + + return { + ...scan, + providerInfo: { + provider: providerData.attributes.provider, + uid: providerData.attributes.uid, + alias: providerData.attributes.alias, + }, + }; + }) || [] + ); +} + +export function ScansTableWithPolling({ + initialData, + initialMeta, + searchParams, +}: ScansTableWithPollingProps) { + const [scansData, setScansData] = useState(initialData); + const [meta, setMeta] = useState(initialMeta); + + // Sync state with server data when props change (e.g., pagination or filter changes). + // useState only uses its argument on first mount, so without this effect, + // navigating to page 2 would change the URL but keep showing page 1 data. + useEffect(() => { + setScansData(initialData); + setMeta(initialMeta); + }, [initialData, initialMeta]); + + const hasExecutingScan = scansData.some((scan) => + EXECUTING_STATES.includes( + scan.attributes.state as (typeof EXECUTING_STATES)[number], + ), + ); + + const handleRefresh = useCallback(async () => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + const sort = searchParams.sort?.toString(); + + const filters = Object.fromEntries( + Object.entries(searchParams).filter( + ([key]) => key.startsWith("filter[") && key !== "scanId", + ), + ); + + const query = (filters["filter[search]"] as string) || ""; + + const result = await getScans({ + query, + page, + sort, + filters, + pageSize, + include: "provider", + }); + + if (result?.data) { + const expanded = expandScansWithProviderInfo( + result.data, + result.included, + ); + setScansData(expanded); + + if (result && "meta" in result) { + setMeta(result.meta as MetaDataProps); + } + } + }, [searchParams]); + + // Listen for scan launch events to trigger an immediate refresh + useEffect(() => { + const handler = () => { + handleRefresh(); + }; + window.addEventListener(SCAN_LAUNCHED_EVENT, handler); + return () => window.removeEventListener(SCAN_LAUNCHED_EVENT, handler); + }, [handleRefresh]); + + return ( + <> + + + + ); +} diff --git a/ui/components/shadcn/checkbox/checkbox.tsx b/ui/components/shadcn/checkbox/checkbox.tsx index 43da49eb3e..930b70a236 100644 --- a/ui/components/shadcn/checkbox/checkbox.tsx +++ b/ui/components/shadcn/checkbox/checkbox.tsx @@ -1,24 +1,54 @@ "use client"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, MinusIcon } from "lucide-react"; import { cn } from "@/lib/utils"; +const SIZE_STYLES = { + default: { + root: "size-6", + icon: "size-4", + }, + sm: { + root: "size-5", + icon: "size-3.5", + }, +} as const; + +type CheckboxSize = keyof typeof SIZE_STYLES; + +interface CheckboxProps + extends React.ComponentProps { + /** Size variant: "default" (24px) or "sm" (20px) */ + size?: CheckboxSize; + /** Show indeterminate state (minus icon) - used for partial selection in trees */ + indeterminate?: boolean; +} + function Checkbox({ className, + size = "default", + indeterminate, + checked, ...props -}: React.ComponentProps) { +}: CheckboxProps) { + const sizeStyles = SIZE_STYLES[size]; + return ( - + {indeterminate ? ( + + ) : ( + + )} ); } export { Checkbox }; +export type { CheckboxProps, CheckboxSize }; diff --git a/ui/components/shadcn/checkbox/index.ts b/ui/components/shadcn/checkbox/index.ts new file mode 100644 index 0000000000..bbf3fc318c --- /dev/null +++ b/ui/components/shadcn/checkbox/index.ts @@ -0,0 +1,2 @@ +export type { CheckboxProps, CheckboxSize } from "./checkbox"; +export { Checkbox } from "./checkbox"; diff --git a/ui/components/shadcn/dropdown/action-dropdown.tsx b/ui/components/shadcn/dropdown/action-dropdown.tsx index d272872129..fe44da2757 100644 --- a/ui/components/shadcn/dropdown/action-dropdown.tsx +++ b/ui/components/shadcn/dropdown/action-dropdown.tsx @@ -9,7 +9,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown"; @@ -17,8 +16,6 @@ import { interface ActionDropdownProps { /** The dropdown trigger element. Defaults to a vertical dots icon button */ trigger?: ReactNode; - /** Label shown at the top of the dropdown */ - label?: string; /** Alignment of the dropdown content */ align?: "start" | "center" | "end"; /** Additional className for the content */ @@ -30,7 +27,6 @@ interface ActionDropdownProps { export function ActionDropdown({ trigger, - label = "Actions", align = "end", className, ariaLabel = "Open actions menu", @@ -52,16 +48,10 @@ export function ActionDropdown({ - {label && ( - <> - {label} - - - )} {children} @@ -91,8 +81,8 @@ export function ActionDropdownItem({ return ( svg]:size-5", - destructive && "text-destructive", + "text-muted-foreground mt-0.5 shrink-0 [&>svg]:size-4", + destructive && "text-text-error-primary", )} > {icon} @@ -113,7 +103,7 @@ export function ActionDropdownItem({ {description} @@ -124,8 +114,18 @@ export function ActionDropdownItem({ ); } -// Re-export commonly used components for convenience -export { - DropdownMenuLabel as ActionDropdownLabel, - DropdownMenuSeparator as ActionDropdownSeparator, -} from "./dropdown"; +export function ActionDropdownDangerZone({ + children, +}: { + children: ReactNode; +}) { + return ( + <> + + + Danger zone + + {children} + + ); +} diff --git a/ui/components/shadcn/dropdown/index.ts b/ui/components/shadcn/dropdown/index.ts index d21a2ff982..06169c0668 100644 --- a/ui/components/shadcn/dropdown/index.ts +++ b/ui/components/shadcn/dropdown/index.ts @@ -1,8 +1,7 @@ export { ActionDropdown, + ActionDropdownDangerZone, ActionDropdownItem, - ActionDropdownLabel, - ActionDropdownSeparator, } from "./action-dropdown"; export { DropdownMenu, diff --git a/ui/components/shadcn/tree-view/index.ts b/ui/components/shadcn/tree-view/index.ts new file mode 100644 index 0000000000..6483d3d611 --- /dev/null +++ b/ui/components/shadcn/tree-view/index.ts @@ -0,0 +1,13 @@ +export { TreeItemLabel } from "./tree-item-label"; +export { TreeLeaf } from "./tree-leaf"; +export { TreeNode } from "./tree-node"; +export { TreeSpinner } from "./tree-spinner"; +export { TreeStatusIcon } from "./tree-status-icon"; +export { TreeView } from "./tree-view"; +export { + getAllDescendantIds, + getTreeLeafPadding, + getTreeNodePadding, + TREE_INDENT_REM, + TREE_LEAF_EXTRA_PADDING_REM, +} from "./utils"; diff --git a/ui/components/shadcn/tree-view/tree-item-label.tsx b/ui/components/shadcn/tree-view/tree-item-label.tsx new file mode 100644 index 0000000000..8a40484c14 --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-item-label.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { TreeDataItem } from "@/types/tree"; + +interface TreeItemLabelProps { + /** The tree item to display */ + item: TreeDataItem; + /** Optional text size class (defaults to text-base) */ + textClassName?: string; +} + +/** + * TreeItemLabel component - displays an item's icon and name with truncation. + * + * Features: + * - Optional icon rendering + * - Text truncation with tooltip on hover + * - Consistent layout across tree nodes and leaves + * + * This component extracts the common pattern used in TreeNode and TreeLeaf + * for displaying item content with overflow handling. + */ +export function TreeItemLabel({ item, textClassName }: TreeItemLabelProps) { + return ( +
+ {item.icon && } + + + + {item.name} + + + {item.name} + +
+ ); +} diff --git a/ui/components/shadcn/tree-view/tree-leaf.tsx b/ui/components/shadcn/tree-view/tree-leaf.tsx new file mode 100644 index 0000000000..a9cb6c815b --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-leaf.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { KeyboardEvent } from "react"; + +import { Checkbox } from "@/components/shadcn/checkbox"; +import { cn } from "@/lib/utils"; +import { TreeLeafProps } from "@/types/tree"; + +import { TreeItemLabel } from "./tree-item-label"; +import { TreeSpinner } from "./tree-spinner"; +import { TreeStatusIcon } from "./tree-status-icon"; +import { getTreeLeafPadding } from "./utils"; + +/** + * TreeLeaf component for rendering leaf nodes (nodes without children). + * + * Features: + * - Selection via checkbox or click + * - Loading spinner state + * - Custom rendering via renderItem prop + * - Indentation based on nesting level + * - Keyboard navigation support (Enter/Space to select) + */ +export function TreeLeaf({ + item, + level, + selectedIds, + onSelectionChange, + showCheckboxes, + renderItem, +}: TreeLeafProps) { + const isSelected = selectedIds.includes(item.id); + + const handleSelect = () => { + if (!item.disabled) { + onSelectionChange(item.id, item); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSelect(); + } + }; + + return ( +
+ {item.isLoading && } + {!item.isLoading && item.status && ( + + )} + + {showCheckboxes && ( + e.stopPropagation()} + /> + )} + + {renderItem ? ( + renderItem({ + item, + level, + isLeaf: true, + isSelected, + hasChildren: false, + }) + ) : ( + + )} +
+ ); +} diff --git a/ui/components/shadcn/tree-view/tree-node.tsx b/ui/components/shadcn/tree-view/tree-node.tsx new file mode 100644 index 0000000000..6c2a11e63d --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-node.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronRightIcon } from "lucide-react"; +import { KeyboardEvent } from "react"; + +import { Checkbox } from "@/components/shadcn/checkbox"; +import { cn } from "@/lib/utils"; +import { TreeNodeProps } from "@/types/tree"; + +import { TreeItemLabel } from "./tree-item-label"; +import { TreeLeaf } from "./tree-leaf"; +import { TreeSpinner } from "./tree-spinner"; +import { TreeStatusIcon } from "./tree-status-icon"; +import { getAllDescendantIds, getTreeNodePadding } from "./utils"; + +/** + * TreeNode component for rendering expandable nodes with children. + * + * Features: + * - Collapsible content using Radix UI + * - Indeterminate checkbox state when partially selected + * - Recursive selection of all descendants + * - Loading spinner state + * - Custom rendering via renderItem prop + * - Keyboard navigation support (Enter/Space to select, Arrow keys to expand) + */ +export function TreeNode({ + item, + level, + selectedIds, + expandedIds, + onSelectionChange, + onExpandedChange, + showCheckboxes, + renderItem, + expandAll, + enableSelectChildren, +}: TreeNodeProps) { + const isExpanded = expandAll || expandedIds.includes(item.id); + const isSelected = selectedIds.includes(item.id); + + // Calculate indeterminate state based on descendant selection + const descendantIds = getAllDescendantIds(item); + const selectedDescendantCount = descendantIds.filter((id) => + selectedIds.includes(id), + ).length; + const isIndeterminate = + selectedDescendantCount > 0 && + selectedDescendantCount < descendantIds.length; + + const handleToggleExpand = () => { + const newExpandedIds = isExpanded + ? expandedIds.filter((id) => id !== item.id) + : [...expandedIds, item.id]; + onExpandedChange(newExpandedIds); + }; + + const handleSelect = () => { + onSelectionChange(item.id, item); + }; + + const handleContentClick = showCheckboxes ? handleSelect : handleToggleExpand; + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "Enter": + case " ": + event.preventDefault(); + handleContentClick(); + break; + case "ArrowRight": + if (!isExpanded) { + event.preventDefault(); + handleToggleExpand(); + } + break; + case "ArrowLeft": + if (isExpanded) { + event.preventDefault(); + handleToggleExpand(); + } + break; + } + }; + + return ( +
+
+ + + {!item.isLoading && item.status && ( + + )} + + {showCheckboxes && ( + e.stopPropagation()} + /> + )} + +
+ {renderItem ? ( + renderItem({ + item, + level, + isLeaf: false, + isSelected, + isExpanded, + isIndeterminate, + hasChildren: true, + }) + ) : ( + + )} +
+
+ + + {isExpanded && ( + + {item.children?.map((child) => ( +
  • + {child.children ? ( + + ) : ( + + )} +
  • + ))} +
    + )} +
    +
    + ); +} diff --git a/ui/components/shadcn/tree-view/tree-spinner.tsx b/ui/components/shadcn/tree-view/tree-spinner.tsx new file mode 100644 index 0000000000..6527cb2ccb --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-spinner.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface TreeSpinnerProps { + className?: string; +} + +/** + * TreeSpinner component - a circular loading indicator for tree nodes. + * + * Features: + * - 20x20 (size-5) default size to match checkbox sm + * - 2.5px stroke for good visibility + * - Uses button-primary color + * - Smooth rotation animation + */ +export function TreeSpinner({ className }: TreeSpinnerProps) { + return ( + + {/* Background track */} + + {/* Animated arc */} + + + ); +} diff --git a/ui/components/shadcn/tree-view/tree-status-icon.tsx b/ui/components/shadcn/tree-view/tree-status-icon.tsx new file mode 100644 index 0000000000..1ef34024b1 --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-status-icon.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { CircleCheckIcon, CircleXIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { TREE_ITEM_STATUS, TreeItemStatus } from "@/types/tree"; + +interface TreeStatusIconProps { + status: TreeItemStatus; + className?: string; +} + +/** + * TreeStatusIcon component - displays success or error status for tree nodes. + * + * Features: + * - CircleCheck icon for success (green) + * - CircleX icon for error (red) + * - Same size as TreeSpinner for consistent layout + */ +export function TreeStatusIcon({ status, className }: TreeStatusIconProps) { + if (status === TREE_ITEM_STATUS.SUCCESS) { + return ( + + ); + } + + if (status === TREE_ITEM_STATUS.ERROR) { + return ( + + ); + } + + return null; +} diff --git a/ui/components/shadcn/tree-view/tree-view.tsx b/ui/components/shadcn/tree-view/tree-view.tsx new file mode 100644 index 0000000000..940574677d --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-view.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; + +import { cn } from "@/lib/utils"; +import { TreeDataItem, TreeViewProps } from "@/types/tree"; + +import { TreeLeaf } from "./tree-leaf"; +import { TreeNode } from "./tree-node"; +import { getAllDescendantIds } from "./utils"; + +/** + * TreeView component for rendering hierarchical data structures. + * + * Features: + * - Recursive nested structure support + * - Controlled or uncontrolled selection state + * - Controlled or uncontrolled expansion state + * - Auto-select children when parent is selected (optional) + * - Indeterminate checkbox state for partial selection + * - Loading states with spinners + * - Custom rendering via renderItem prop + * + * @example + * ```tsx + * const data: TreeDataItem[] = [ + * { + * id: "org-1", + * name: "Organization", + * children: [ + * { id: "acc-1", name: "Account 1" }, + * { id: "acc-2", name: "Account 2" }, + * ], + * }, + * ]; + * + * + * ``` + */ +export function TreeView({ + data, + className, + selectedIds: controlledSelectedIds, + onSelectionChange, + expandedIds: controlledExpandedIds, + onExpandedChange, + expandAll = false, + showCheckboxes = false, + enableSelectChildren = true, + renderItem, +}: TreeViewProps) { + const [internalSelectedIds, setInternalSelectedIds] = useState([]); + const [internalExpandedIds, setInternalExpandedIds] = useState([]); + + const selectedIds = controlledSelectedIds ?? internalSelectedIds; + const expandedIds = controlledExpandedIds ?? internalExpandedIds; + + const handleSelectionChange = (itemId: string, item: TreeDataItem) => { + const isSelected = selectedIds.includes(itemId); + let newSelectedIds: string[]; + + if (enableSelectChildren && item.children) { + // When selecting a parent, also select/deselect all descendants + const allItemIds = getAllDescendantIds(item, true); + if (isSelected) { + // Deselect this item and all descendants + newSelectedIds = selectedIds.filter((id) => !allItemIds.includes(id)); + } else { + // Select this item and all descendants + newSelectedIds = Array.from(new Set([...selectedIds, ...allItemIds])); + } + } else { + // Simple toggle without affecting children + newSelectedIds = isSelected + ? selectedIds.filter((id) => id !== itemId) + : [...selectedIds, itemId]; + } + + if (onSelectionChange) { + onSelectionChange(newSelectedIds); + } else { + setInternalSelectedIds(newSelectedIds); + } + }; + + const handleExpandedChange = (newExpandedIds: string[]) => { + if (onExpandedChange) { + onExpandedChange(newExpandedIds); + } else { + setInternalExpandedIds(newExpandedIds); + } + }; + + const items = Array.isArray(data) ? data : [data]; + + return ( +
    +
      + {items.map((item) => ( +
    • + {item.children ? ( + + ) : ( + + )} +
    • + ))} +
    +
    + ); +} diff --git a/ui/components/shadcn/tree-view/utils.ts b/ui/components/shadcn/tree-view/utils.ts new file mode 100644 index 0000000000..1df696518d --- /dev/null +++ b/ui/components/shadcn/tree-view/utils.ts @@ -0,0 +1,50 @@ +import { TreeDataItem } from "@/types/tree"; + +/** + * Tree indentation constants (in rem units). + * Used to calculate consistent padding for nested tree items. + */ +export const TREE_INDENT_REM = 1.25; +export const TREE_LEAF_EXTRA_PADDING_REM = 1.5; + +/** + * Calculates the left padding for a tree node based on its nesting level. + */ +export function getTreeNodePadding(level: number): string { + return `${level * TREE_INDENT_REM}rem`; +} + +/** + * Calculates the left padding for a tree leaf based on its nesting level. + * Leaves have extra padding to align with node content (accounting for expand button). + */ +export function getTreeLeafPadding(level: number): string { + return `${level * TREE_INDENT_REM + TREE_LEAF_EXTRA_PADDING_REM}rem`; +} + +/** + * Recursively collects all descendant IDs from a tree item. + * + * @param item - The tree item to collect IDs from + * @param includeParent - Whether to include the parent item's ID (default: false) + * @returns Array of all descendant IDs (and optionally the parent ID) + * + * @example + * Get only descendant IDs + * const childIds = getAllDescendantIds(parentItem); + * + * Get parent + all descendant IDs + * const allIds = getAllDescendantIds(parentItem, true); + */ +export function getAllDescendantIds( + item: TreeDataItem, + includeParent = false, +): string[] { + const ids = includeParent ? [item.id] : []; + if (item.children) { + for (const child of item.children) { + ids.push(child.id, ...getAllDescendantIds(child, false)); + } + } + return ids; +} diff --git a/ui/components/ui/table/data-table-animated-row.tsx b/ui/components/ui/table/data-table-animated-row.tsx new file mode 100644 index 0000000000..8ea153df4d --- /dev/null +++ b/ui/components/ui/table/data-table-animated-row.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Cell, flexRender, Row } from "@tanstack/react-table"; +import { motion } from "framer-motion"; + +import { cn } from "@/lib/utils"; + +interface DataTableAnimatedRowProps { + row: Row; +} + +/** + * DataTableAnimatedRow renders a table row with smooth expand/collapse animations. + * + * The trick: You cannot animate height directly (tables ignore it). + * Instead, we wrap each cell's content in a motion.div and animate THAT. + * + * How it works: + * 1. The itself is not animated + * 2. Each contains a motion.div wrapper + * 3. The wrapper animates height from 0 to "auto" + * 4. overflow-hidden clips content during animation + * 5. Padding is on the inner content, not the td + */ +export function DataTableAnimatedRow({ + row, +}: DataTableAnimatedRowProps) { + return ( + td:first-child]:rounded-l-full [&>td:last-child]:rounded-r-full", + "hover:bg-bg-neutral-tertiary", + "data-[state=selected]:bg-bg-neutral-tertiary", + )} + > + {row.getVisibleCells().map((cell: Cell, index, cells) => { + const isFirst = index === 0; + const isLast = index === cells.length - 1; + + return ( + + +
    + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
    +
    + + ); + })} +
    + ); +} diff --git a/ui/components/ui/table/data-table-expand-all-toggle.tsx b/ui/components/ui/table/data-table-expand-all-toggle.tsx new file mode 100644 index 0000000000..c53782f451 --- /dev/null +++ b/ui/components/ui/table/data-table-expand-all-toggle.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Table } from "@tanstack/react-table"; +import { Maximize2Icon, Minimize2Icon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +interface DataTableExpandAllToggleProps { + table: Table; +} + +/** + * DataTableExpandAllToggle provides a button in the table header to expand + * or collapse all rows at once. + * + * Features: + * - Shows Maximize2 icon when rows are collapsed (click to expand all) + * - Shows Minimize2 icon when rows are expanded (click to collapse all) + * - Accessible with proper aria-label + * - Only renders when the table has expandable rows + * + * @example + * ```tsx + * // In column definition header: + * { + * id: "name", + * header: ({ table }) => ( + *
    + * + * Name + *
    + * ), + * cell: ({ row }) => ( + * + * {row.original.name} + * + * ), + * } + * ``` + */ +export function DataTableExpandAllToggle({ + table, +}: DataTableExpandAllToggleProps) { + const isAllExpanded = table.getIsAllRowsExpanded(); + const canExpand = table.getCanSomeRowsExpand(); + + if (!canExpand) { + return null; + } + + return ( + + ); +} diff --git a/ui/components/ui/table/data-table-expand-toggle.tsx b/ui/components/ui/table/data-table-expand-toggle.tsx new file mode 100644 index 0000000000..4258ee8a0b --- /dev/null +++ b/ui/components/ui/table/data-table-expand-toggle.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Row } from "@tanstack/react-table"; +import { ChevronRightIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +interface DataTableExpandToggleProps { + row: Row; +} + +/** + * DataTableExpandToggle provides a clickable chevron button for expanding/collapsing + * table rows that have sub-rows. + * + * Features: + * - Only shows for rows that can expand (have sub-rows) + * - Provides consistent spacing for rows without sub-rows + * - Animates chevron rotation on expand/collapse + * - Accessible with proper aria-label + * + * @example + * ```tsx + * // In column definition: + * { + * id: "expand", + * cell: ({ row }) => , + * } + * ``` + */ +export function DataTableExpandToggle({ + row, +}: DataTableExpandToggleProps) { + if (!row.getCanExpand()) { + // Return a spacer div for alignment when row has no sub-rows + return
    ; + } + + return ( + + ); +} diff --git a/ui/components/ui/table/data-table-expandable-cell.tsx b/ui/components/ui/table/data-table-expandable-cell.tsx new file mode 100644 index 0000000000..78bfa651cf --- /dev/null +++ b/ui/components/ui/table/data-table-expandable-cell.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Row } from "@tanstack/react-table"; +import { CornerDownRightIcon } from "lucide-react"; + +import { DataTableExpandToggle } from "./data-table-expand-toggle"; + +/** Indentation per nesting level in rem units */ +const INDENT_PER_LEVEL_REM = 1.5; + +interface DataTableExpandableCellProps { + row: Row; + children: React.ReactNode; + /** Whether to show the expand/collapse toggle (default: true) */ + showToggle?: boolean; +} + +/** + * DataTableExpandableCell is a wrapper component for table cells that need + * to display content with proper indentation for nested rows. + * + * Features: + * - Automatically indents content based on row depth + * - Shows CornerDownRight icon for child rows (depth > 0) + * - Optionally includes the expand/collapse toggle for parent rows + * - Maintains proper alignment for all nesting levels + * + * @example + * ```tsx + * // In column definition: + * { + * accessorKey: "name", + * header: "Name", + * cell: ({ row }) => ( + * + * {row.original.name} + * + * ), + * } + * ``` + */ +export function DataTableExpandableCell({ + row, + children, + showToggle = true, +}: DataTableExpandableCellProps) { + const isChildRow = row.depth > 0; + const canExpand = row.getCanExpand(); + + return ( +
    + {showToggle && ( + <> + {canExpand ? ( + + ) : isChildRow ? ( + + ) : ( +
    + )} + + )} + {children} +
    + ); +} diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index c34f446e05..3fbdd780cc 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -3,8 +3,10 @@ import { ColumnDef, ColumnFiltersState, + ExpandedState, flexRender, getCoreRowModel, + getExpandedRowModel, getFilteredRowModel, getSortedRowModel, OnChangeFn, @@ -13,7 +15,8 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; -import { useState } from "react"; +import { AnimatePresence } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; import { Table, @@ -23,12 +26,20 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { DataTableAnimatedRow } from "@/components/ui/table/data-table-animated-row"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; import { DataTableSearch } from "@/components/ui/table/data-table-search"; import { useFilterTransitionOptional } from "@/contexts"; import { cn } from "@/lib"; import { FilterOption, MetaDataProps } from "@/types"; +/** + * Default column size used by TanStack Table when no explicit size is set. + * We skip applying inline width styles for columns with this default value + * to allow them to flex naturally within the table layout. + */ +const DEFAULT_COLUMN_SIZE = 150; + interface DataTableProviderProps { columns: ColumnDef[]; data: TData[]; @@ -42,6 +53,16 @@ interface DataTableProviderProps { getRowCanSelect?: (row: Row) => boolean; /** Show search bar in the table toolbar */ showSearch?: boolean; + /** Function to extract sub-rows from a row for hierarchical data */ + getSubRows?: (row: TData) => TData[] | undefined; + /** Controlled expanded state */ + expanded?: ExpandedState; + /** Callback when expanded state changes */ + onExpandedChange?: OnChangeFn; + /** Auto-select children when parent selected (default: true) */ + enableSubRowSelection?: boolean; + /** Expand all rows by default, or provide specific expanded state */ + defaultExpanded?: boolean | ExpandedState; /** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */ paramPrefix?: string; @@ -87,6 +108,11 @@ export function DataTable({ onRowSelectionChange, getRowCanSelect, showSearch = false, + getSubRows, + expanded: controlledExpanded, + onExpandedChange, + enableSubRowSelection = true, + defaultExpanded, paramPrefix = "", controlledSearch, onSearchChange, @@ -98,6 +124,12 @@ export function DataTable({ }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); + // ExpandedState should be a Record for individual row control + // Note: We don't use `true` (boolean) as it makes rows permanently expanded + const [expanded, setExpanded] = useState(() => { + if (typeof defaultExpanded === "object") return defaultExpanded; + return {}; + }); // Get transition state from context for loading indicator const filterTransition = useFilterTransitionOptional(); @@ -116,18 +148,48 @@ export function DataTable({ getFilteredRowModel: getFilteredRowModel(), onRowSelectionChange, manualPagination: true, + // Expansion support for hierarchical data + getSubRows, + getExpandedRowModel: getSubRows ? getExpandedRowModel() : undefined, + enableSubRowSelection, + onExpandedChange: onExpandedChange ?? setExpanded, state: { sorting, columnFilters, rowSelection: rowSelection ?? {}, + expanded: controlledExpanded ?? expanded, }, }); + // Track whether initial expansion has been applied + const hasInitiallyExpanded = useRef(false); + + // Expand all rows on mount when defaultExpanded={true} + useEffect(() => { + if ( + !hasInitiallyExpanded.current && + defaultExpanded === true && + getSubRows + ) { + table.toggleAllRowsExpanded(true); + hasInitiallyExpanded.current = true; + } + }, [defaultExpanded, getSubRows, table]); + // Calculate selection key to force header re-render when selection changes const selectionKey = rowSelection ? Object.keys(rowSelection).filter((k) => rowSelection[k]).length : 0; + // Calculate expansion key to force header re-render when expansion changes + const currentExpanded = controlledExpanded ?? expanded; + const expansionKey = + currentExpanded === true + ? "all" + : typeof currentExpanded === "object" + ? Object.keys(currentExpanded).filter((k) => currentExpanded[k]).length + : 0; + // Format total entries count const totalEntries = metadata?.pagination?.count ?? 0; const formattedTotal = totalEntries.toLocaleString(); @@ -161,13 +223,21 @@ export function DataTable({ )}
    )} - +
    {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { + const size = header.getSize(); return ( - + {header.isPlaceholder ? null : flexRender( @@ -181,26 +251,38 @@ export function DataTable({ ))} - {rows?.length ? ( - rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + + {rows?.length ? ( + rows.map((row) => + getSubRows && row.depth > 0 ? ( + + ) : ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ), + ) + ) : ( + + + No results. + - )) - ) : ( - - - No results. - - - )} + )} +
    {metadata && ( diff --git a/ui/components/users/profile/api-keys/data-table-row-actions.tsx b/ui/components/users/profile/api-keys/data-table-row-actions.tsx index 51af064c8a..b47eaab0cc 100644 --- a/ui/components/users/profile/api-keys/data-table-row-actions.tsx +++ b/ui/components/users/profile/api-keys/data-table-row-actions.tsx @@ -1,21 +1,15 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; -import { - DeleteDocumentBulkIcon, - EditDocumentBulkIcon, -} from "@heroui/shared-icons"; import { Row } from "@tanstack/react-table"; -import clsx from "clsx"; +import { Pencil, Trash2 } from "lucide-react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownDangerZone, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import { EnrichedApiKey } from "./types"; @@ -25,8 +19,6 @@ interface DataTableRowActionsProps { onRevoke: (apiKey: EnrichedApiKey) => void; } -const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0"; - export function DataTableRowActions({ row, onEdit, @@ -39,53 +31,29 @@ export function DataTableRowActions({ return (
    - - + - - - - } - onPress={() => onEdit(apiKey)} - > - Edit name - - - {canRevoke ? ( - - - } - onPress={() => onRevoke(apiKey)} - > - Revoke - - - ) : null} - - + } + > + } + label="Edit API Key" + onSelect={() => onEdit(apiKey)} + /> + {canRevoke && ( + + } + label="Revoke API Key" + destructive + onSelect={() => onRevoke(apiKey)} + /> + + )} +
    ); } diff --git a/ui/components/users/table/data-table-row-actions.tsx b/ui/components/users/table/data-table-row-actions.tsx index 41f81290da..fafabb5011 100644 --- a/ui/components/users/table/data-table-row-actions.tsx +++ b/ui/components/users/table/data-table-row-actions.tsx @@ -1,22 +1,16 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; -import { - DeleteDocumentBulkIcon, - EditDocumentBulkIcon, -} from "@heroui/shared-icons"; import { Row } from "@tanstack/react-table"; -import clsx from "clsx"; +import { Pencil, Trash2 } from "lucide-react"; import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownDangerZone, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; import { DeleteForm, EditForm } from "../forms"; @@ -25,7 +19,6 @@ interface DataTableRowActionsProps { row: Row; roles?: { id: string; name: string }[]; } -const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0"; export function DataTableRowActions({ row, @@ -66,51 +59,27 @@ export function DataTableRowActions({
    - - + - - - - } - onPress={() => setIsEditOpen(true)} - > - Edit User - - - - - } - onPress={() => setIsDeleteOpen(true)} - > - Delete User - - - - + } + > + } + label="Edit User" + onSelect={() => setIsEditOpen(true)} + /> + + } + label="Delete User" + destructive + onSelect={() => setIsDeleteOpen(true)} + /> + +
    ); diff --git a/ui/contexts/filter-transition-context.tsx b/ui/contexts/filter-transition-context.tsx index cd529e0a3c..b283829e3d 100644 --- a/ui/contexts/filter-transition-context.tsx +++ b/ui/contexts/filter-transition-context.tsx @@ -1,16 +1,17 @@ "use client"; +import { useSearchParams } from "next/navigation"; import { createContext, ReactNode, - TransitionStartFunction, useContext, - useTransition, + useEffect, + useState, } from "react"; interface FilterTransitionContextType { isPending: boolean; - startTransition: TransitionStartFunction; + signalFilterChange: () => void; } const FilterTransitionContext = createContext< @@ -39,13 +40,33 @@ interface FilterTransitionProviderProps { children: ReactNode; } +/** + * Provides a shared pending state for filter changes. + * + * Filter components signal the start of navigation via signalFilterChange(), + * and use their own local useTransition() for the actual router.push(). + * This avoids a known Next.js production bug where a shared useTransition() + * wrapping router.push() causes the navigation to be silently reverted. + * + * The pending state auto-resets when searchParams change (navigation completed). + */ export const FilterTransitionProvider = ({ children, }: FilterTransitionProviderProps) => { - const [isPending, startTransition] = useTransition(); + const searchParams = useSearchParams(); + const [isPending, setIsPending] = useState(false); + + // Auto-reset pending state when searchParams change (navigation completed) + useEffect(() => { + setIsPending(false); + }, [searchParams]); + + const signalFilterChange = () => { + setIsPending(true); + }; return ( - + {children} ); diff --git a/ui/hooks/use-related-filters.ts b/ui/hooks/use-related-filters.ts index 0fc4ce2de5..424b360e51 100644 --- a/ui/hooks/use-related-filters.ts +++ b/ui/hooks/use-related-filters.ts @@ -1,7 +1,5 @@ import { useSearchParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import { useUrlFilters } from "@/hooks/use-url-filters"; import { isScanEntity } from "@/lib/helper-filters"; import { FilterEntity, @@ -21,6 +19,19 @@ interface UseRelatedFiltersProps { providerFilterType?: FilterType.PROVIDER | FilterType.PROVIDER_UID; } +/** + * Derives available providers and scans based on the current URL filters. + * + * Pure computation — no effects, no state, no navigation. The returned + * lists update automatically when searchParams change because the component + * re-renders with new searchParams from Next.js. + * + * Cascading filter cleanup (e.g. auto-clearing a scan when its provider is + * deselected) is handled atomically by the filter components themselves + * (ProviderTypeSelector clears provider_id__in, AccountsSelector updates + * provider_type__in). This avoids the production bug where router.push() + * calls inside useEffect would silently abort pending navigations. + */ export const useRelatedFilters = ({ providerIds = [], providerUIDs = [], @@ -31,33 +42,18 @@ export const useRelatedFilters = ({ providerFilterType = FilterType.PROVIDER, }: UseRelatedFiltersProps) => { const searchParams = useSearchParams(); - const { updateFilter } = useUrlFilters(); - const [availableScans, setAvailableScans] = - useState(completedScanIds); - // Use providerIds if provided (for findings), otherwise use providerUIDs (for scans) const providers = providerIds.length > 0 ? providerIds : providerUIDs; - const [availableProviders, setAvailableProviders] = - useState(providers); - const previousProviders = useRef([]); - const previousProviderTypes = useRef([]); - const isManualDeselection = useRef(false); - const getScanProvider = (scanId: string) => { - if (!enableScanRelation) return null; - const scanDetail = scanDetails.find( - (detail) => Object.keys(detail)[0] === scanId, - ); - return scanDetail ? scanDetail[scanId]?.providerInfo?.uid : null; - }; + const providerParam = searchParams.get(`filter[${providerFilterType}]`); + const providerTypeParam = searchParams.get( + `filter[${FilterType.PROVIDER_TYPE}]`, + ); - const getScanProviderType = (scanId: string): ProviderType | null => { - if (!enableScanRelation) return null; - const scanDetail = scanDetails.find( - (detail) => Object.keys(detail)[0] === scanId, - ); - return scanDetail ? scanDetail[scanId]?.providerInfo?.provider : null; - }; + const currentProviders = providerParam ? providerParam.split(",") : []; + const currentProviderTypes = providerTypeParam + ? (providerTypeParam.split(",") as ProviderType[]) + : []; const getProviderType = (providerKey: string): ProviderType | null => { const providerDetail = providerDetails.find( @@ -72,128 +68,28 @@ export const useRelatedFilters = ({ return null; }; - useEffect(() => { - const scanParam = enableScanRelation - ? searchParams.get(`filter[${FilterType.SCAN}]`) - : null; - const providerParam = searchParams.get(`filter[${providerFilterType}]`); - const providerTypeParam = searchParams.get( - `filter[${FilterType.PROVIDER_TYPE}]`, - ); + // Derive available providers filtered by selected provider types + const availableProviders = + currentProviderTypes.length > 0 + ? providers.filter((key) => { + const providerType = getProviderType(key); + return providerType && currentProviderTypes.includes(providerType); + }) + : providers; - const currentProviders = providerParam ? providerParam.split(",") : []; - const currentProviderTypes = providerTypeParam - ? (providerTypeParam.split(",") as ProviderType[]) - : []; + // Derive available scans filtered by selected providers and provider types + const availableScans = enableScanRelation + ? currentProviders.length > 0 || currentProviderTypes.length > 0 + ? completedScanIds.filter((scanId) => { + const scanDetail = scanDetails.find( + (detail) => Object.keys(detail)[0] === scanId, + ); + if (!scanDetail) return false; - // Detect deselected items - const deselectedProviders = previousProviders.current.filter( - (provider) => !currentProviders.includes(provider), - ); - const deselectedProviderTypes = previousProviderTypes.current.filter( - (type) => !currentProviderTypes.includes(type), - ); - - // Check if it's a manual deselection - if (deselectedProviderTypes.length > 0) { - isManualDeselection.current = true; - } else if ( - currentProviderTypes.length === 0 && - previousProviderTypes.current.length === 0 - ) { - isManualDeselection.current = false; - } - - // Update references - previousProviders.current = currentProviders; - previousProviderTypes.current = currentProviderTypes; - - // Handle scan selection logic - if (enableScanRelation && scanParam) { - const scanProviderId = getScanProvider(scanParam); - const scanProviderType = getScanProviderType(scanParam); - - const shouldDeselectScan = - (scanProviderId && - (deselectedProviders.includes(scanProviderId) || - (currentProviders.length > 0 && - !currentProviders.includes(scanProviderId)))) || - (scanProviderType && - !isManualDeselection.current && - (deselectedProviderTypes.includes(scanProviderType) || - (currentProviderTypes.length > 0 && - !currentProviderTypes.includes(scanProviderType)))); - - if (shouldDeselectScan) { - updateFilter(FilterType.SCAN, null); - // } else { - // // Add provider if not already selected - // if (scanProviderId && !currentProviders.includes(scanProviderId)) { - // updateFilter(FilterType.PROVIDER_UID, [ - // ...currentProviders, - // scanProviderId, - // ]); - // } - - // // Only add provider type if there are none selected - // if ( - // scanProviderType && - // currentProviderTypes.length === 0 && - // !isManualDeselection.current - // ) { - // updateFilter(FilterType.PROVIDER_TYPE, [scanProviderType]); - // } - } - } - - // // Handle provider selection logic - // if ( - // currentProviders.length > 0 && - // deselectedProviders.length === 0 && - // !isManualDeselection.current - // ) { - // const providerTypes = currentProviders - // .map(getProviderType) - // .filter((type): type is ProviderType => type !== null); - // const selectedProviderTypes = Array.from(new Set(providerTypes)); - - // if ( - // selectedProviderTypes.length > 0 && - // currentProviderTypes.length === 0 - // ) { - // updateFilter(FilterType.PROVIDER_TYPE, selectedProviderTypes); - // } - // } - - // Update available providers - if (currentProviderTypes.length > 0) { - const filteredProviders = providers.filter((key) => { - const providerType = getProviderType(key); - return providerType && currentProviderTypes.includes(providerType); - }); - setAvailableProviders(filteredProviders); - - const validProviders = currentProviders.filter((key) => { - const providerType = getProviderType(key); - return providerType && currentProviderTypes.includes(providerType); - }); - - if (validProviders.length !== currentProviders.length) { - updateFilter( - providerFilterType, - validProviders.length > 0 ? validProviders : null, - ); - } - } else { - setAvailableProviders(providers); - } - - // Update available scans - if (enableScanRelation) { - if (currentProviders.length > 0 || currentProviderTypes.length > 0) { - const filteredScans = completedScanIds.filter((scanId) => { - const scanProviderId = getScanProvider(scanId); - const scanProviderType = getScanProviderType(scanId); + const scanProviderId = scanDetail[scanId]?.providerInfo?.uid ?? null; + const scanProviderType = + (scanDetail[scanId]?.providerInfo?.provider as ProviderType) ?? + null; return ( (currentProviders.length === 0 || @@ -202,14 +98,9 @@ export const useRelatedFilters = ({ (scanProviderType && currentProviderTypes.includes(scanProviderType))) ); - }); - setAvailableScans(filteredScans); - } else { - setAvailableScans(completedScanIds); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams]); + }) + : completedScanIds + : completedScanIds; return { availableProviderIds: providerIds.length > 0 ? availableProviders : [], diff --git a/ui/hooks/use-url-filters.ts b/ui/hooks/use-url-filters.ts index 911aa61558..e0afa764f4 100644 --- a/ui/hooks/use-url-filters.ts +++ b/ui/hooks/use-url-filters.ts @@ -1,89 +1,90 @@ "use client"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useTransition } from "react"; import { useFilterTransitionOptional } from "@/contexts"; +const FINDINGS_PATH = "/findings"; +const DEFAULT_MUTED_FILTER = "false"; + /** * Custom hook to handle URL filters and automatically reset * pagination when filters change. * - * Uses useTransition to prevent full page reloads when filters change, - * keeping the current UI visible while the new data loads. - * - * When used within a FilterTransitionProvider, the transition state is shared - * across all components using this hook, enabling coordinated loading indicators. + * Uses client-side router navigation to update query params without + * full page reloads when filters change. */ export const useUrlFilters = () => { const router = useRouter(); const searchParams = useSearchParams(); const pathname = usePathname(); + const filterTransition = useFilterTransitionOptional(); + const isPending = false; - // Use shared context if available, otherwise fall back to local transition - const sharedTransition = useFilterTransitionOptional(); - const [localIsPending, localStartTransition] = useTransition(); + const ensureFindingsDefaultMuted = (params: URLSearchParams) => { + // Findings defaults to excluding muted findings unless user sets it explicitly. + if (pathname === FINDINGS_PATH && !params.has("filter[muted]")) { + params.set("filter[muted]", DEFAULT_MUTED_FILTER); + } + }; - const isPending = sharedTransition?.isPending ?? localIsPending; - const startTransition = - sharedTransition?.startTransition ?? localStartTransition; + const navigate = (params: URLSearchParams) => { + ensureFindingsDefaultMuted(params); - const updateFilter = useCallback( - (key: string, value: string | string[] | null) => { - const params = new URLSearchParams(searchParams.toString()); + const queryString = params.toString(); + if (queryString === searchParams.toString()) return; - const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; + const targetUrl = queryString ? `${pathname}?${queryString}` : pathname; + filterTransition?.signalFilterChange(); + router.push(targetUrl, { scroll: false }); + }; - const currentValue = params.get(filterKey); - const nextValue = Array.isArray(value) - ? value.length > 0 - ? value.join(",") - : null - : value === null - ? null - : value; + const updateFilter = (key: string, value: string | string[] | null) => { + const params = new URLSearchParams(searchParams.toString()); - // If effective value is unchanged, do nothing (avoids redundant fetches) - if (currentValue === nextValue) return; + const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; - // Only reset page to 1 if page parameter already exists - if (params.has("page")) { - params.set("page", "1"); - } + const currentValue = params.get(filterKey); + const nextValue = Array.isArray(value) + ? value.length > 0 + ? value.join(",") + : null + : value === null + ? null + : value; - if (nextValue === null) { - params.delete(filterKey); - } else { - params.set(filterKey, nextValue); - } + // If effective value is unchanged, do nothing (avoids redundant fetches) + if (currentValue === nextValue) return; - startTransition(() => { - router.push(`${pathname}?${params.toString()}`, { scroll: false }); - }); - }, - [router, searchParams, pathname, startTransition], - ); - - const clearFilter = useCallback( - (key: string) => { - const params = new URLSearchParams(searchParams.toString()); - const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } + if (nextValue === null) { params.delete(filterKey); + } else { + params.set(filterKey, nextValue); + } - // Only reset page to 1 if page parameter already exists - if (params.has("page")) { - params.set("page", "1"); - } + navigate(params); + }; - startTransition(() => { - router.push(`${pathname}?${params.toString()}`, { scroll: false }); - }); - }, - [router, searchParams, pathname, startTransition], - ); + const clearFilter = (key: string) => { + const params = new URLSearchParams(searchParams.toString()); + const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; - const clearAllFilters = useCallback(() => { + params.delete(filterKey); + + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } + + navigate(params); + }; + + const clearAllFilters = () => { const params = new URLSearchParams(searchParams.toString()); Array.from(params.keys()).forEach((key) => { if (key.startsWith("filter[") || key === "sort") { @@ -93,17 +94,33 @@ export const useUrlFilters = () => { params.delete("page"); - startTransition(() => { - router.push(`${pathname}?${params.toString()}`, { scroll: false }); - }); - }, [router, searchParams, pathname, startTransition]); + navigate(params); + }; - const hasFilters = useCallback(() => { + const hasFilters = () => { const params = new URLSearchParams(searchParams.toString()); return Array.from(params.keys()).some( (key) => key.startsWith("filter[") || key === "sort", ); - }, [searchParams]); + }; + + /** + * Low-level navigation function for complex filter updates that need + * to modify multiple params atomically (e.g., setting provider_type + * while clearing provider_id). The modifier receives a mutable + * URLSearchParams; page is auto-reset if already present. + */ + const navigateWithParams = (modifier: (params: URLSearchParams) => void) => { + const params = new URLSearchParams(searchParams.toString()); + modifier(params); + + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } + + navigate(params); + }; return { updateFilter, @@ -111,5 +128,6 @@ export const useUrlFilters = () => { clearAllFilters, hasFilters, isPending, + navigateWithParams, }; }; diff --git a/ui/package.json b/ui/package.json index 46ee0bead2..5a79cb5cce 100644 --- a/ui/package.json +++ b/ui/package.json @@ -161,5 +161,6 @@ "@react-aria/interactions>react": "19.2.4" } }, - "version": "0.0.1" + "version": "0.0.1", + "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a" } diff --git a/ui/proxy.ts b/ui/proxy.ts index 5827f39844..98b0725dea 100644 --- a/ui/proxy.ts +++ b/ui/proxy.ts @@ -50,20 +50,6 @@ export default auth((req: NextRequest & { auth: any }) => { } } - // Redirect /findings to include default muted filter if not present - if ( - pathname === "/findings" && - !req.nextUrl.searchParams.has("filter[muted]") - ) { - const findingsUrl = new URL("/findings", req.url); - // Preserve existing search params - req.nextUrl.searchParams.forEach((value, key) => { - findingsUrl.searchParams.set(key, value); - }); - findingsUrl.searchParams.set("filter[muted]", "false"); - return NextResponse.redirect(findingsUrl); - } - return NextResponse.next(); }); diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 50dcd9c892..696db0db19 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -43,6 +43,12 @@ export interface OCIProviderData { alias?: string; } +// AlibabaCloud provider data +export interface AlibabaCloudProviderData { + accountId: string; + alias?: string; +} + // AWS credential options export const AWS_CREDENTIAL_OPTIONS = { AWS_ROLE_ARN: "role", @@ -167,6 +173,25 @@ export interface OCIProviderCredential { region?: string; } +// AlibabaCloud credential options +export const ALIBABACLOUD_CREDENTIAL_OPTIONS = { + ALIBABACLOUD_CREDENTIALS: "credentials", + ALIBABACLOUD_ROLE: "role", +} as const; + +// AlibabaCloud credential type +type AlibabaCloudCredentialType = + (typeof ALIBABACLOUD_CREDENTIAL_OPTIONS)[keyof typeof ALIBABACLOUD_CREDENTIAL_OPTIONS]; + +// AlibabaCloud provider credential +export interface AlibabaCloudProviderCredential { + type: AlibabaCloudCredentialType; + accessKeyId: string; + accessKeySecret: string; + roleArn?: string; + roleSessionName?: string; +} + // Providers page export class ProvidersPage extends BasePage { // Alias input @@ -184,6 +209,7 @@ export class ProvidersPage extends BasePage { readonly kubernetesProviderRadio: Locator; readonly githubProviderRadio: Locator; readonly ociProviderRadio: Locator; + readonly alibabacloudProviderRadio: Locator; // AWS provider form elements readonly accountIdInput: Locator; @@ -247,6 +273,15 @@ export class ProvidersPage extends BasePage { readonly ociKeyContentInput: Locator; readonly ociRegionInput: Locator; + // AlibabaCloud provider form elements + readonly alibabacloudAccountIdInput: Locator; + readonly alibabacloudAccessKeyIdInput: Locator; + readonly alibabacloudAccessKeySecretInput: Locator; + readonly alibabacloudRoleArnInput: Locator; + readonly alibabacloudRoleSessionNameInput: Locator; + readonly alibabacloudStaticCredentialsRadio: Locator; + readonly alibabacloudRoleCredentialsRadio: Locator; + // Delete button readonly deleteProviderConfirmationButton: Locator; @@ -290,6 +325,10 @@ export class ProvidersPage extends BasePage { this.ociProviderRadio = page.getByRole("option", { name: /Oracle Cloud Infrastructure/i, }); + // Alibaba Cloud + this.alibabacloudProviderRadio = page.getByRole("option", { + name: /Alibaba Cloud/i, + }); // AWS provider form inputs this.accountIdInput = page.getByRole("textbox", { name: "Account ID" }); @@ -354,6 +393,30 @@ export class ProvidersPage extends BasePage { }); this.ociRegionInput = page.getByRole("textbox", { name: /Region/i }); + // AlibabaCloud provider form inputs + this.alibabacloudAccountIdInput = page.getByRole("textbox", { + name: "Account ID", + }); + this.alibabacloudAccessKeyIdInput = page.getByRole("textbox", { + name: "Access Key ID", + }); + this.alibabacloudAccessKeySecretInput = page.getByRole("textbox", { + name: "Access Key Secret", + }); + this.alibabacloudRoleArnInput = page.getByRole("textbox", { + name: "Role ARN", + }); + this.alibabacloudRoleSessionNameInput = page.getByRole("textbox", { + name: "Role Session Name", + }); + // Radios for selecting AlibabaCloud credentials method + this.alibabacloudStaticCredentialsRadio = page.getByRole("radio", { + name: /Connect via Access Keys/i, + }); + this.alibabacloudRoleCredentialsRadio = page.getByRole("radio", { + name: /Connect assuming RAM Role/i, + }); + // Alias input this.aliasInput = page.getByRole("textbox", { name: "Provider alias (optional)", @@ -878,6 +941,101 @@ export class ProvidersPage extends BasePage { await expect(this.ociRegionInput).toBeVisible(); } + async selectAlibabaCloudProvider(): Promise { + await this.selectProviderRadio(this.alibabacloudProviderRadio); + } + + async fillAlibabaCloudProviderDetails( + data: AlibabaCloudProviderData, + ): Promise { + // Fill the AlibabaCloud provider details + + await this.alibabacloudAccountIdInput.fill(data.accountId); + + if (data.alias) { + await this.aliasInput.fill(data.alias); + } + } + + async selectAlibabaCloudCredentialsType( + type: AlibabaCloudCredentialType, + ): Promise { + // Ensure we are on the add-credentials page where the selector exists + + await expect(this.page).toHaveURL(/\/providers\/add-credentials/); + + if (type === ALIBABACLOUD_CREDENTIAL_OPTIONS.ALIBABACLOUD_CREDENTIALS) { + await this.alibabacloudStaticCredentialsRadio.click({ force: true }); + } else if (type === ALIBABACLOUD_CREDENTIAL_OPTIONS.ALIBABACLOUD_ROLE) { + await this.alibabacloudRoleCredentialsRadio.click({ force: true }); + } else { + throw new Error(`Invalid AlibabaCloud credential type: ${type}`); + } + } + + async fillAlibabaCloudStaticCredentials( + credentials: AlibabaCloudProviderCredential, + ): Promise { + // Fill the AlibabaCloud static credentials form + + if (credentials.accessKeyId) { + await this.alibabacloudAccessKeyIdInput.fill(credentials.accessKeyId); + } + if (credentials.accessKeySecret) { + await this.alibabacloudAccessKeySecretInput.fill( + credentials.accessKeySecret, + ); + } + } + + async fillAlibabaCloudRoleCredentials( + credentials: AlibabaCloudProviderCredential, + ): Promise { + // Fill the AlibabaCloud RAM Role credentials form + + if (credentials.roleArn) { + await this.alibabacloudRoleArnInput.fill(credentials.roleArn); + } + if (credentials.accessKeyId) { + await this.alibabacloudAccessKeyIdInput.fill(credentials.accessKeyId); + } + if (credentials.accessKeySecret) { + await this.alibabacloudAccessKeySecretInput.fill( + credentials.accessKeySecret, + ); + } + if (credentials.roleSessionName) { + await this.alibabacloudRoleSessionNameInput.fill( + credentials.roleSessionName, + ); + } + } + + async verifyAlibabaCloudCredentialsPageLoaded(): Promise { + // Verify the AlibabaCloud credentials page is loaded + + await this.verifyPageHasProwlerTitle(); + await expect(this.alibabacloudStaticCredentialsRadio).toBeVisible(); + await expect(this.alibabacloudRoleCredentialsRadio).toBeVisible(); + } + + async verifyAlibabaCloudStaticCredentialsPageLoaded(): Promise { + // Verify the AlibabaCloud static credentials page is loaded + + await this.verifyPageHasProwlerTitle(); + await expect(this.alibabacloudAccessKeyIdInput).toBeVisible(); + await expect(this.alibabacloudAccessKeySecretInput).toBeVisible(); + } + + async verifyAlibabaCloudRoleCredentialsPageLoaded(): Promise { + // Verify the AlibabaCloud RAM Role credentials page is loaded + + await this.verifyPageHasProwlerTitle(); + await expect(this.alibabacloudRoleArnInput).toBeVisible(); + await expect(this.alibabacloudAccessKeyIdInput).toBeVisible(); + await expect(this.alibabacloudAccessKeySecretInput).toBeVisible(); + } + async verifyPageLoaded(): Promise { // Verify the providers page is loaded @@ -896,6 +1054,7 @@ export class ProvidersPage extends BasePage { await expect(this.m365ProviderRadio).toBeVisible(); await expect(this.kubernetesProviderRadio).toBeVisible(); await expect(this.githubProviderRadio).toBeVisible(); + await expect(this.alibabacloudProviderRadio).toBeVisible(); } async verifyCredentialsPageLoaded(): Promise { diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index f58d9edc19..d7ff8e1106 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -766,3 +766,128 @@ - Requires PROVIDER-E2E-012 to be run first to create the OCI provider - This test validates the fix for OCI update credentials form failing silently due to missing provider UID - The provider UID is required for OCI credential validation (tenancy field auto-populated from UID) + +--- + +## Test Case: `PROVIDER-E2E-014` - Add AlibabaCloud Provider with Static Credentials + +**Priority:** `critical` + +**Tags:** + +- type → @e2e, @serial +- feature → @providers +- provider → @alibabacloud + +**Description/Objective:** Validates the complete flow of adding a new Alibaba Cloud provider using static credentials (Access Key ID and Access Key Secret) + +**Preconditions:** + +- Admin user authentication required (admin.auth.setup setup) +- Environment variables configured: E2E_ALIBABACLOUD_ACCOUNT_ID, E2E_ALIBABACLOUD_ACCESS_KEY_ID, E2E_ALIBABACLOUD_ACCESS_KEY_SECRET +- Remove any existing provider with the same Account ID before starting the test +- This test must be run serially and never in parallel with other tests, as it requires the Account ID not to be already registered beforehand. + +### Flow Steps: + +1. Navigate to providers page +2. Click "Add Provider" button +3. Select AlibabaCloud provider type +4. Fill provider details (account ID and alias) +5. Verify AlibabaCloud credentials page is loaded +6. Select static credentials type +7. Verify static credentials page is loaded +8. Fill AlibabaCloud credentials (access key ID and access key secret) +9. Launch initial scan +10. Verify redirect to Scans page +11. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") + +### Expected Result: + +- AlibabaCloud provider successfully added with static credentials +- Initial scan launched successfully +- User redirected to Scans page +- Scheduled scan appears in Scans table with correct provider and scan name + +### Key verification points: + +- Provider page loads correctly +- Connect account page displays AlibabaCloud option +- Provider details form accepts account ID and alias +- Credentials page loads with credential type selection +- Static credentials page loads with access key ID and access key secret fields +- Static credentials are properly filled in the correct fields +- Launch scan page appears +- Successful redirect to Scans page after scan launch +- Provider exists in Scans table (verified by account ID) +- Scan name field contains "scheduled scan" + +### Notes: + +- Test uses environment variables for AlibabaCloud credentials +- Provider cleanup performed before each test to ensure clean state +- Requires valid Alibaba Cloud account with appropriate permissions +- Static credentials must have sufficient permissions for security scanning + +--- + +## Test Case: `PROVIDER-E2E-015` - Add AlibabaCloud Provider with RAM Role Credentials + +**Priority:** `critical` + +**Tags:** + +- type → @e2e, @serial +- feature → @providers +- provider → @alibabacloud + +**Description/Objective:** Validates the complete flow of adding a new Alibaba Cloud provider using RAM Role credentials (Access Key ID, Access Key Secret, and Role ARN) + +**Preconditions:** + +- Admin user authentication required (admin.auth.setup setup) +- Environment variables configured: E2E_ALIBABACLOUD_ACCOUNT_ID, E2E_ALIBABACLOUD_ACCESS_KEY_ID, E2E_ALIBABACLOUD_ACCESS_KEY_SECRET, E2E_ALIBABACLOUD_ROLE_ARN +- Remove any existing provider with the same Account ID before starting the test +- This test must be run serially and never in parallel with other tests, as it requires the Account ID not to be already registered beforehand. + +### Flow Steps: + +1. Navigate to providers page +2. Click "Add Provider" button +3. Select AlibabaCloud provider type +4. Fill provider details (account ID and alias) +5. Verify AlibabaCloud credentials page is loaded +6. Select RAM Role credentials type +7. Verify RAM Role credentials page is loaded +8. Fill AlibabaCloud RAM Role credentials (access key ID, access key secret, and role ARN) +9. Launch initial scan +10. Verify redirect to Scans page +11. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") + +### Expected Result: + +- AlibabaCloud provider successfully added with RAM Role credentials +- Initial scan launched successfully +- User redirected to Scans page +- Scheduled scan appears in Scans table with correct provider and scan name + +### Key verification points: + +- Provider page loads correctly +- Connect account page displays AlibabaCloud option +- Provider details form accepts account ID and alias +- Credentials page loads with credential type selection +- RAM Role credentials page loads with access key ID, access key secret, and role ARN fields +- RAM Role credentials are properly filled in the correct fields +- Launch scan page appears +- Successful redirect to Scans page after scan launch +- Provider exists in Scans table (verified by account ID) +- Scan name field contains "scheduled scan" + +### Notes: + +- Test uses environment variables for AlibabaCloud RAM Role credentials +- Provider cleanup performed before each test to ensure clean state +- Requires valid Alibaba Cloud account with RAM Role configured +- RAM Role must have sufficient permissions for security scanning +- Role ARN must be properly configured and assumable diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 07da089891..f19b20b454 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -22,6 +22,9 @@ import { OCIProviderData, OCIProviderCredential, OCI_CREDENTIAL_OPTIONS, + AlibabaCloudProviderData, + AlibabaCloudProviderCredential, + ALIBABACLOUD_CREDENTIAL_OPTIONS, } from "./providers-page"; import { ScansPage } from "../scans/scans-page"; import fs from "fs"; @@ -1138,6 +1141,192 @@ test.describe("Add Provider", () => { }, ); }); + + test.describe.serial("Add AlibabaCloud Provider", () => { + // Providers page object + let providersPage: ProvidersPage; + let scansPage: ScansPage; + + // Test data from environment variables + const accountId = process.env.E2E_ALIBABACLOUD_ACCOUNT_ID; + const accessKeyId = process.env.E2E_ALIBABACLOUD_ACCESS_KEY_ID; + const accessKeySecret = process.env.E2E_ALIBABACLOUD_ACCESS_KEY_SECRET; + const roleArn = process.env.E2E_ALIBABACLOUD_ROLE_ARN; + + // Validate required environment variable for beforeEach + if (!accountId) { + throw new Error( + "E2E_ALIBABACLOUD_ACCOUNT_ID environment variable is not set", + ); + } + + // Setup before each test + test.beforeEach(async ({ page }) => { + providersPage = new ProvidersPage(page); + // Clean up existing provider to ensure clean test state + await deleteProviderIfExists(providersPage, accountId); + }); + + // Use admin authentication for provider management + test.use({ storageState: "playwright/.auth/admin_user.json" }); + + test( + "should add a new AlibabaCloud provider with static credentials", + { + tag: [ + "@critical", + "@e2e", + "@providers", + "@alibabacloud", + "@serial", + "@PROVIDER-E2E-014", + ], + }, + async ({ page }) => { + // Validate required environment variables + if (!accessKeyId || !accessKeySecret) { + throw new Error( + "E2E_ALIBABACLOUD_ACCESS_KEY_ID and E2E_ALIBABACLOUD_ACCESS_KEY_SECRET environment variables are not set", + ); + } + + // Prepare test data for AlibabaCloud provider + const alibabacloudProviderData: AlibabaCloudProviderData = { + accountId: accountId, + alias: "Test E2E AlibabaCloud Account - Static Credentials", + }; + + // Prepare static credentials + const staticCredentials: AlibabaCloudProviderCredential = { + type: ALIBABACLOUD_CREDENTIAL_OPTIONS.ALIBABACLOUD_CREDENTIALS, + accessKeyId: accessKeyId, + accessKeySecret: accessKeySecret, + }; + + // Navigate to providers page + await providersPage.goto(); + await providersPage.verifyPageLoaded(); + + // Start adding new provider + await providersPage.clickAddProvider(); + await providersPage.verifyConnectAccountPageLoaded(); + + // Select AlibabaCloud provider + await providersPage.selectAlibabaCloudProvider(); + + // Fill provider details + await providersPage.fillAlibabaCloudProviderDetails( + alibabacloudProviderData, + ); + await providersPage.clickNext(); + + // Verify credentials page is loaded + await providersPage.verifyAlibabaCloudCredentialsPageLoaded(); + + // Select static credentials type + await providersPage.selectAlibabaCloudCredentialsType( + ALIBABACLOUD_CREDENTIAL_OPTIONS.ALIBABACLOUD_CREDENTIALS, + ); + + // Verify static credentials page is loaded + await providersPage.verifyAlibabaCloudStaticCredentialsPageLoaded(); + + // Fill static credentials + await providersPage.fillAlibabaCloudStaticCredentials(staticCredentials); + await providersPage.clickNext(); + + // Launch scan + await providersPage.verifyLaunchScanPageLoaded(); + await providersPage.clickNext(); + + // Wait for redirect to scan page + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); + + // Verify scan status is "Scheduled scan" + await scansPage.verifyScheduledScanStatus(accountId); + }, + ); + + test( + "should add a new AlibabaCloud provider with RAM Role credentials", + { + tag: [ + "@critical", + "@e2e", + "@providers", + "@alibabacloud", + "@serial", + "@PROVIDER-E2E-015", + ], + }, + async ({ page }) => { + // Validate required environment variables + if (!accessKeyId || !accessKeySecret || !roleArn) { + throw new Error( + "E2E_ALIBABACLOUD_ACCESS_KEY_ID, E2E_ALIBABACLOUD_ACCESS_KEY_SECRET, and E2E_ALIBABACLOUD_ROLE_ARN environment variables are not set", + ); + } + + // Prepare test data for AlibabaCloud provider + const alibabacloudProviderData: AlibabaCloudProviderData = { + accountId: accountId, + alias: "Test E2E AlibabaCloud Account - RAM Role Credentials", + }; + + // Prepare RAM Role credentials + const roleCredentials: AlibabaCloudProviderCredential = { + type: ALIBABACLOUD_CREDENTIAL_OPTIONS.ALIBABACLOUD_ROLE, + accessKeyId: accessKeyId, + accessKeySecret: accessKeySecret, + roleArn: roleArn, + }; + + // Navigate to providers page + await providersPage.goto(); + await providersPage.verifyPageLoaded(); + + // Start adding new provider + await providersPage.clickAddProvider(); + await providersPage.verifyConnectAccountPageLoaded(); + + // Select AlibabaCloud provider + await providersPage.selectAlibabaCloudProvider(); + + // Fill provider details + await providersPage.fillAlibabaCloudProviderDetails( + alibabacloudProviderData, + ); + await providersPage.clickNext(); + + // Verify credentials page is loaded + await providersPage.verifyAlibabaCloudCredentialsPageLoaded(); + + // Select RAM Role credentials type + await providersPage.selectAlibabaCloudCredentialsType( + ALIBABACLOUD_CREDENTIAL_OPTIONS.ALIBABACLOUD_ROLE, + ); + + // Verify RAM Role credentials page is loaded + await providersPage.verifyAlibabaCloudRoleCredentialsPageLoaded(); + + // Fill RAM Role credentials + await providersPage.fillAlibabaCloudRoleCredentials(roleCredentials); + await providersPage.clickNext(); + + // Launch scan + await providersPage.verifyLaunchScanPageLoaded(); + await providersPage.clickNext(); + + // Wait for redirect to scan page + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); + + // Verify scan status is "Scheduled scan" + await scansPage.verifyScheduledScanStatus(accountId); + }, + ); + }); }); test.describe("Update Provider Credentials", () => { diff --git a/ui/types/attack-paths.ts b/ui/types/attack-paths.ts index 7bd623e724..d8b63f12e1 100644 --- a/ui/types/attack-paths.ts +++ b/ui/types/attack-paths.ts @@ -90,11 +90,18 @@ export interface AttackPathQueryParameter { required?: boolean; } +export interface AttackPathQueryAttribution { + text: string; + link: string; +} + export interface AttackPathQueryAttributes { name: string; + short_description: string; description: string; provider: string; parameters: AttackPathQueryParameter[]; + attribution: AttackPathQueryAttribution | null; } export interface AttackPathQuery { diff --git a/ui/types/index.ts b/ui/types/index.ts index 312d9c6e29..badcd6e7f9 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -6,3 +6,4 @@ export * from "./processors"; export * from "./providers"; export * from "./resources"; export * from "./scans"; +export * from "./tree"; diff --git a/ui/types/tree.ts b/ui/types/tree.ts new file mode 100644 index 0000000000..ff18bf77c5 --- /dev/null +++ b/ui/types/tree.ts @@ -0,0 +1,114 @@ +/** + * Tree View Component Types + * + * Types for the TreeView component used to render hierarchical data structures + * with support for selection, expansion, and custom rendering. + */ + +/** + * Status indicator for tree items after loading completes + */ +export const TREE_ITEM_STATUS = { + SUCCESS: "success", + ERROR: "error", +} as const; + +export type TreeItemStatus = + (typeof TREE_ITEM_STATUS)[keyof typeof TREE_ITEM_STATUS]; + +/** + * Represents a single item in the tree structure. + * Items can have nested children to create a hierarchical tree. + */ +export interface TreeDataItem { + /** Unique identifier for the tree item */ + id: string; + /** Display name for the item */ + name: string; + /** Optional icon component to render alongside the name */ + icon?: React.ComponentType<{ className?: string }>; + /** Child items (if present, this node is expandable) */ + children?: TreeDataItem[]; + /** Whether the item is disabled (cannot be selected) */ + disabled?: boolean; + /** Whether the item is in a loading state (shows spinner) */ + isLoading?: boolean; + /** Status indicator shown after loading (success/error) */ + status?: TreeItemStatus; + /** Additional CSS classes for the item */ + className?: string; +} + +/** + * Props for the main TreeView component + */ +export interface TreeViewProps { + /** Tree data - can be a single root or array of roots */ + data: TreeDataItem[] | TreeDataItem; + /** Additional CSS classes for the root container */ + className?: string; + /** Controlled selected item IDs */ + selectedIds?: string[]; + /** Callback when selection changes */ + onSelectionChange?: (selectedIds: string[]) => void; + /** Controlled expanded item IDs */ + expandedIds?: string[]; + /** Callback when expansion state changes */ + onExpandedChange?: (expandedIds: string[]) => void; + /** Expand all nodes by default */ + expandAll?: boolean; + /** Show checkboxes for selection */ + showCheckboxes?: boolean; + /** Auto-select children when parent is selected */ + enableSelectChildren?: boolean; + /** Custom render function for each item */ + renderItem?: (params: TreeRenderItemParams) => React.ReactNode; +} + +/** + * Parameters passed to the custom renderItem function + */ +export interface TreeRenderItemParams { + /** The tree item being rendered */ + item: TreeDataItem; + /** Nesting depth level (0 = root) */ + level: number; + /** Whether this is a leaf node (no children) */ + isLeaf: boolean; + /** Whether this item is selected */ + isSelected: boolean; + /** Whether this item is expanded (only for non-leaf nodes) */ + isExpanded?: boolean; + /** Whether this item has partial child selection (indeterminate state) */ + isIndeterminate?: boolean; + /** Whether this item has children */ + hasChildren: boolean; +} + +/** + * Internal props for TreeNode component (expandable nodes) + */ +export interface TreeNodeProps { + item: TreeDataItem; + level: number; + selectedIds: string[]; + expandedIds: string[]; + onSelectionChange: (id: string, item: TreeDataItem) => void; + onExpandedChange: (ids: string[]) => void; + showCheckboxes: boolean; + renderItem?: (params: TreeRenderItemParams) => React.ReactNode; + expandAll: boolean; + enableSelectChildren: boolean; +} + +/** + * Internal props for TreeLeaf component (non-expandable nodes) + */ +export interface TreeLeafProps { + item: TreeDataItem; + level: number; + selectedIds: string[]; + onSelectionChange: (id: string, item: TreeDataItem) => void; + showCheckboxes: boolean; + renderItem?: (params: TreeRenderItemParams) => React.ReactNode; +}