diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 2952ebc2d50..cbf75b6e551 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -590,6 +590,30 @@ jobs: flags: prowler-py${{ matrix.python-version }}-stackit files: ./stackit_coverage.xml + # E2E Cloud Provider + - name: Check if E2E Cloud files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-e2e + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/e2e/** + ./tests/**/e2e/** + ./uv.lock + + - name: Run E2E Cloud tests + if: steps.changed-e2e.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/e2e --cov-report=xml:e2e_coverage.xml tests/providers/e2e + + - name: Upload E2E Cloud coverage to Codecov + if: steps.changed-e2e.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-e2e + files: ./e2e_coverage.xml + # External Provider (dynamic loading) - name: Check if External Provider files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/docs/developer-guide/e2e-details.mdx b/docs/developer-guide/e2e-details.mdx new file mode 100644 index 00000000000..bebb15c43fb --- /dev/null +++ b/docs/developer-guide/e2e-details.mdx @@ -0,0 +1,46 @@ +--- +title: 'E2E Cloud Provider' +--- + +This page documents the [E2E Cloud](https://www.e2enetworks.com/) provider implementation in Prowler SDK. + +E2E Cloud uses the MyAccount REST API documented at [E2E Cloud API](https://docs.e2enetworks.com/api/myaccount/compute/nodes/). Authentication requires both an API key and a Bearer auth token generated from the MyAccount portal. + +## Authentication + +Set the following environment variables before running scans: + +```bash +export E2E_API_KEY= +export E2E_AUTH_TOKEN= +export E2E_PROJECT_ID= +export E2E_LOCATION=Delhi +``` + +Optional CLI flags (`--e2e-api-key`, `--e2e-auth-token`, `--e2e-project-id`, `--e2e-location`) are available for backward compatibility, but environment variables are preferred. + +## Usage + +```bash +uv run python prowler-cli.py e2e --list-checks +uv run python prowler-cli.py e2e --service node --log-level DEBUG +``` + +## Services and Checks + +The initial release includes four services: + +- `node` — compute node posture (public IP, encryption, compliance, VPC attachment) +- `securitygroup` — security group rules and node attachments +- `loadbalancer` — appliance HTTPS, health checks, BitNinja protection +- `storage` — object storage buckets and block volumes + +Provider code lives under [`prowler/providers/e2e/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/e2e). + +## Architecture Notes + +- API client: [`prowler/providers/e2e/lib/api/client.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/e2e/lib/api/client.py) +- Provider class: [`prowler/providers/e2e/e2e_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/e2e/e2e_provider.py) +- Check reports use `CheckReportE2e` in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) + +Supported regions default to `Delhi` and `Chennai` per the E2E Cloud OpenAPI specification. diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 29b2fd9eccf..7e3295c09de 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the GCP provider, mapping existing GCP checks across the five DORA pillars [(#11642)](https://github.com/prowler-cloud/prowler/pull/11642) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Cloudflare provider, mapping existing Cloudflare edge/network checks across the applicable DORA pillars [(#11645)](https://github.com/prowler-cloud/prowler/pull/11645) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the AlibabaCloud provider, mapping existing AlibabaCloud checks across the applicable DORA pillars [(#11646)](https://github.com/prowler-cloud/prowler/pull/11646) +- E2E Cloud provider with `network`, `database`, and extended `storage` services (32 checks across compute, network, security groups, load balancers, storage, and DBaaS), plus a Cartography-style resource graph schema at `prowler/providers/e2e/docs/schema.md` ### 🔄 Changed diff --git a/prowler/__main__.py b/prowler/__main__.py index 533a7043590..2941b85b2c2 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -156,6 +156,7 @@ from prowler.providers.oraclecloud.models import OCIOutputOptions from prowler.providers.scaleway.models import ScalewayOutputOptions from prowler.providers.stackit.models import StackITOutputOptions +from prowler.providers.e2e.models import E2eOutputOptions from prowler.providers.vercel.models import VercelOutputOptions @@ -431,6 +432,10 @@ def prowler(): output_options = VercelOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "e2e": + output_options = E2eOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) elif provider == "okta": output_options = OktaOutputOptions( args, bulk_checks_metadata, global_provider.identity diff --git a/prowler/config/config.py b/prowler/config/config.py index 9e2b079da6b..430dea928ed 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -80,6 +80,7 @@ class Provider(str, Enum): VERCEL = "vercel" OKTA = "okta" STACKIT = "stackit" + E2E = "e2e" # Compliance diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 28f07c20516..0ce777be55a 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -675,3 +675,8 @@ okta: # Defaults to 15 per DISA STIG V-273187 (OKTA-APP-000025); raise it only # with an explicit risk acceptance. okta_admin_console_idle_timeout_max_minutes: 15 + +# E2E Cloud Configuration +e2e: + # load_balancer_bitninja_enabled + require_bitninja_on_load_balancers: false diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index f9155c68f76..a14fcae44a6 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -1255,6 +1255,29 @@ def __init__(self, metadata: Dict, resource: Any) -> None: self.location = getattr(resource, "location", "kr1") +@dataclass +class CheckReportE2e(Check_Report): + """Contains the E2E Cloud Check's finding information.""" + + resource_name: str + resource_id: str + location: str + + def __init__(self, metadata: Dict, resource: Any) -> None: + """Initialize the E2E Cloud Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the E2E Cloud resource. + """ + super().__init__(metadata, resource) + self.resource_name = getattr( + resource, "name", getattr(resource, "resource_name", "") + ) + self.resource_id = getattr(resource, "id", getattr(resource, "resource_id", "")) + self.location = getattr(resource, "location", "global") + + @dataclass class CheckReportStackIT(Check_Report): """Contains the StackIT Check's finding information.""" diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 2848e9c7880..b70bac49d3e 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -48,6 +48,7 @@ def __init__(self): "nhn", "mongodbatlas", "vercel", + "e2e", "okta", "scaleway", "stackit", @@ -73,10 +74,10 @@ def __init__(self): self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...", + usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,e2e,dashboard,iac,image,llm{extra_providers_csv}}} ...", epilog=f""" Available Cloud Providers: - {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}} + {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,e2e{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -96,7 +97,8 @@ def __init__(self): nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider scaleway Scaleway Provider - vercel Vercel Provider{extra_providers_text} + vercel Vercel Provider + e2e E2E Cloud Provider{extra_providers_text} Available components: diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py index a3cdb1389af..5ad5582da50 100644 --- a/prowler/lib/outputs/compliance/universal/universal_output.py +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -22,6 +22,7 @@ "oraclecloud": ("TenancyId", "account_uid", "Region", "region"), "alibabacloud": ("AccountId", "account_uid", "Region", "region"), "nhn": ("AccountId", "account_uid", "Region", "region"), + "e2e": ("ProjectId", "account_uid", "Location", "region"), } _DEFAULT_HEADERS = ("AccountId", "account_uid", "Region", "region") diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 9f772d17c4a..13017de22f6 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -342,6 +342,18 @@ def generate_output( output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.location + elif provider.type == "e2e": + output_data["auth_method"] = "api_key_and_bearer_token" + output_data["account_uid"] = str( + get_nested_attribute(provider, "identity.project_id") + ) + output_data["account_name"] = str( + get_nested_attribute(provider, "identity.project_id") + ) + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = check_output.location + elif provider.type == "stackit": output_data["auth_method"] = getattr( provider, "auth_method", "api_token" diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 545bcbc4569..c253441af8f 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -1396,6 +1396,45 @@ def get_googleworkspace_assessment_summary(provider: Provider) -> str: ) return "" + @staticmethod + def get_e2e_assessment_summary(provider: Provider) -> str: + """Get the HTML assessment summary for the E2E Cloud provider.""" + try: + locations = ", ".join(provider.identity.locations) + return f""" +
+
+
+ E2E Cloud Assessment Summary +
+
    +
  • + Project ID: {provider.identity.project_id} +
  • +
  • + Locations: {locations} +
  • +
+
+
+
+
+
+ E2E Cloud Credentials +
+
    +
  • + Authentication: API Key + Bearer Token +
  • +
+
+
""" + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_vercel_assessment_summary(provider: Provider) -> str: """ diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index a1f37a9dfc0..31f0e5094bf 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -24,6 +24,8 @@ def stdout_report(finding, color, verbose, status, fix, provider=None): details = finding.location elif finding.check_metadata.Provider == "nhn": details = finding.location + elif finding.check_metadata.Provider == "e2e": + details = finding.location elif finding.check_metadata.Provider == "stackit": details = finding.location elif finding.check_metadata.Provider == "llm": diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 43d4a547c18..d1fdf103ed5 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -70,6 +70,9 @@ def display_summary_table( elif provider.type == "nhn": entity_type = "Tenant Domain" audited_entities = provider.identity.tenant_domain + elif provider.type == "e2e": + entity_type = "Project" + audited_entities = str(provider.identity.project_id) elif provider.type == "stackit": if provider.identity.project_name: entity_type = "Project" diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 77ae9180a3c..0217e14863f 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -561,6 +561,16 @@ def init_global_provider(arguments: Namespace) -> None: mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif arguments.provider == "e2e": + provider_class( + api_key=getattr(arguments, "e2e_api_key", None), + auth_token=getattr(arguments, "e2e_auth_token", None), + project_id=getattr(arguments, "e2e_project_id", None), + locations=getattr(arguments, "e2e_location", None), + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) elif arguments.provider == "okta": provider_class( okta_org_domain=getattr(arguments, "okta_org_domain", ""), diff --git a/prowler/providers/e2e/__init__.py b/prowler/providers/e2e/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/docs/schema.md b/prowler/providers/e2e/docs/schema.md new file mode 100644 index 00000000000..7d176821cc7 --- /dev/null +++ b/prowler/providers/e2e/docs/schema.md @@ -0,0 +1,368 @@ +## E2E Schema + +Representation of resources in [E2E Cloud MyAccount](https://docs.e2enetworks.com/api/myaccount/openapi.yaml). This schema mirrors the [Cartography module format](https://github.com/cartography-cncf/cartography/tree/master/docs/root/modules) for graph-based security analysis and Attack Paths query authoring. + +### E2eProject + +Representation of an E2E Cloud project (tenant scope for all MyAccount resources). + +> **Ontology Mapping**: This node has the extra label `Tenant` to enable cross-platform queries for organizational tenants across different systems (e.g., AWSAccount, GCPProject, AzureSubscription). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | E2E project ID (`project_id` query parameter) | +| locations | List of deployment regions (e.g. `Delhi`, `Chennai`) | + +#### Relationships + +- All E2E resources belong to an `E2eProject`. + + ```cypher + (:E2eProject)-[:RESOURCE]->(:E2eNode, + :E2eVpc, + :E2eVpcTunnel, + :E2eReservedIp, + :E2eSecurityGroup, + :E2eLoadBalancer, + :E2eBlockVolume, + :E2eStorageBucket, + :E2eEfs, + :E2eEpfs, + :E2eDatabaseCluster, + :E2eDatabaseInstance) + ``` + +### E2eNode + +Representation of an E2E Cloud compute node ([Nodes API](https://docs.e2enetworks.com/api/myaccount/compute/nodes/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Node ID | +| name | Node display name | +| vm_id | Virtual machine identifier used by network and security group APIs | +| status | Node lifecycle status (e.g. `Running`) | +| location | Deployment region (`Delhi`, `Chennai`) | +| public_ip_address | Public IP address if assigned | +| private_ip_address | Private IP address | +| is_vpc_attached | Whether the node is attached to a VPC | +| is_encryption_enabled | Whether encryption at rest is enabled | +| is_accidental_protection | Whether accidental deletion protection is enabled | +| is_node_compliance | Whether compliance monitoring is enabled | +| rescue_mode_status | Rescue mode state (`Enabled` / `Disabled`) | + +#### Relationships + +- A compute node may be a member of a VPC. + + ```cypher + (:E2eNode)-[:MEMBER_OF_VPC]->(:E2eVpc) + ``` + +- A compute node uses one or more security groups. + + ```cypher + (:E2eNode)-[:USES]->(:E2eSecurityGroup) + ``` + +- Block volumes attach to compute nodes. + + ```cypher + (:E2eBlockVolume)-[:ATTACHED_TO]->(:E2eNode) + ``` + +- Reserved or floating IPs attach to compute nodes. + + ```cypher + (:E2eReservedIp)-[:ATTACHED_TO]->(:E2eNode) + ``` + +### E2eVpc + +Representation of an E2E Cloud VPC ([VPC API](https://docs.e2enetworks.com/api/myaccount/network/vpc/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | VPC network ID (`network_id`) | +| name | VPC name | +| location | Deployment region | +| ipv4_cidr | IPv4 CIDR block | +| is_active | Whether the VPC is active | +| state | VPC state (e.g. `Active`) | +| vm_count | Number of nodes attached to the VPC | +| gateway_node_id | Gateway node ID for the VPC | +| gateway_public_ip | Public IP of the VPC gateway node | + +#### Relationships + +- A VPC contains VPC peering tunnels. + + ```cypher + (:E2eVpc)-[:CONTAINS]->(:E2eVpcTunnel) + ``` + +- A VPC gateway is backed by a compute node. + + ```cypher + (:E2eVpc)-[:GATEWAY_NODE]->(:E2eNode) + ``` + +- EFS and EPFS volumes attach to VPCs. + + ```cypher + (:E2eEfs)-[:ATTACHED_TO_VPC]->(:E2eVpc) + (:E2eEpfs)-[:ATTACHED_TO_VPC]->(:E2eVpc) + ``` + +### E2eVpcTunnel + +Representation of an E2E Cloud VPC peering tunnel ([VPC Tunnels API](https://docs.e2enetworks.com/api/myaccount/network/vpc/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Tunnel ID | +| name | Tunnel name | +| location | Deployment region | +| local_vpc_network_id | Local VPC network ID | +| status | Tunnel status (e.g. `CREATING`, `ACTIVE`) | +| is_peer_vpc_external | Whether the peer VPC is external to the account | + +#### Relationships + +- A VPC tunnel peers two VPCs. + + ```cypher + (:E2eVpcTunnel)-[:PEERS_WITH]->(:E2eVpc) + (:E2eVpc)-[:PEERS_WITH]->(:E2eVpc) + ``` + +### E2eReservedIp + +Representation of a reserved, public, addon, or floating IP ([Reserve IP API](https://docs.e2enetworks.com/api/myaccount/network/reserve-ip/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Reserve IP ID | +| ip_address | IP address | +| location | Deployment region | +| status | Attachment status (e.g. `Attached`) | +| reserved_type | IP type (`FloatingIP`, `PublicIP`, `AddonIP`) | +| vm_id | Attached VM ID if applicable | + +#### Relationships + +- Reserved IPs attach to compute nodes. + + ```cypher + (:E2eReservedIp)-[:ATTACHED_TO]->(:E2eNode) + ``` + +### E2eSecurityGroup + +Representation of an E2E Cloud security group ([Security Group API](https://docs.e2enetworks.com/api/myaccount/network/security-group/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Security group ID | +| name | Security group name | +| location | Deployment region | +| is_default | Whether this is the default security group | +| is_all_traffic_rule | Whether an allow-all-traffic rule is present | +| description | Security group description | + +#### Relationships + +- Security groups attach to compute nodes. + + ```cypher + (:E2eSecurityGroup)-[:ATTACHED_TO]->(:E2eNode) + ``` + +- Security groups may attach to load balancers. + + ```cypher + (:E2eSecurityGroup)-[:ATTACHED_TO]->(:E2eLoadBalancer) + ``` + +### E2eLoadBalancer + +Representation of an E2E Cloud load balancer appliance ([Appliances API](https://docs.e2enetworks.com/api/myaccount/compute/load-balancer/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Load balancer appliance ID | +| name | Load balancer name | +| location | Deployment region | +| status | Appliance status | +| lb_mode | Load balancer mode (e.g. ALB HTTPS) | +| ssl_certificate_id | SSL certificate ID for HTTPS listeners | +| bitninja_enabled | Whether BitNinja protection is enabled | +| public_ip | Public IP address | + +#### Relationships + +- Load balancers route traffic to backend nodes. + + ```cypher + (:E2eLoadBalancer)-[:BACKEND]->(:E2eNode) + ``` + +### E2eBlockVolume + +Representation of an E2E Cloud block storage volume ([Block Storage API](https://docs.e2enetworks.com/api/myaccount/storage/block-storage/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Block volume ID (`block_id`) | +| name | Volume name | +| location | Deployment region | +| status | Volume status (e.g. `Available`) | +| size_string | Human-readable volume size | +| is_attached | Whether the volume is attached to a node | + +#### Relationships + +- Block volumes attach to compute nodes. + + ```cypher + (:E2eBlockVolume)-[:ATTACHED_TO]->(:E2eNode) + ``` + +### E2eStorageBucket + +Representation of an E2E Cloud object storage bucket ([Object Storage API](https://docs.e2enetworks.com/api/myaccount/storage/object-storage/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Bucket ID | +| name | Bucket name | +| location | Deployment region | +| status | Bucket status | +| versioning_status | Versioning state (`Off`, `Enabled`, `Suspended`) | +| is_public_access_enabled | Whether public access is enabled | +| is_encryption_enabled | Whether encryption is enabled | +| is_lock_enabled | Whether object lock is enabled | +| lifecycle_configuration_status | Lifecycle policy status | + +#### Relationships + +- Object storage buckets belong to a project. + + ```cypher + (:E2eProject)-[:RESOURCE]->(:E2eStorageBucket) + ``` + +### E2eEfs + +Representation of an E2E Cloud shared file system (SFS / EFS) ([SFS API](https://docs.e2enetworks.com/api/myaccount/storage/parallel-file-storage/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | SFS volume ID | +| name | Volume name | +| location | Deployment region | +| status | Volume status | +| vpc_id | Attached VPC ID | +| is_backup_enabled | Whether backup is enabled | +| is_all_vpc_resources_allowed | Whether all VPC resources can access the volume | + +#### Relationships + +- EFS volumes attach to VPCs. + + ```cypher + (:E2eEfs)-[:ATTACHED_TO_VPC]->(:E2eVpc) + ``` + +### E2eEpfs + +Representation of an E2E Cloud elastic parallel file system (EPFS). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | EPFS volume ID | +| name | Volume name | +| location | Deployment region | +| vpc_network_id | Attached VPC network ID | +| deleted | Whether the volume is marked deleted | + +#### Relationships + +- EPFS volumes attach to VPCs. + + ```cypher + (:E2eEpfs)-[:ATTACHED_TO_VPC]->(:E2eVpc) + ``` + +### E2eDatabaseCluster + +Representation of an E2E Cloud DBaaS cluster ([RDS API](https://docs.e2enetworks.com/api/myaccount/database/rds/)). + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Cluster ID | +| name | Cluster name | +| location | Deployment region | +| status | Cluster status (e.g. `RUNNING`) | +| software_name | Database engine name (e.g. `MySQL`, `PostgreSQL`) | +| software_version | Database engine version | +| backup_enabled | Whether automated backup is enabled | +| master_ssl_enabled | Whether SSL is enabled on the master node | +| master_has_public_ip | Whether the master node has a public IP | + +#### Relationships + +- A database cluster has one or more database instances. + + ```cypher + (:E2eDatabaseCluster)-[:HAS_INSTANCE]->(:E2eDatabaseInstance) + ``` + +### E2eDatabaseInstance + +Representation of a master or replica instance within an E2E Cloud DBaaS cluster. + +| Field | Description | +|-------|-------------| +| firstseen | Timestamp of when a sync job discovered this node | +| lastupdated | Timestamp of the last time the node was updated | +| **id** | Instance ID | +| name | Instance node name | +| cluster_id | Parent cluster ID | +| location | Deployment region | +| role | Instance role (`master` or `replica`) | +| public_ip_address | Public IP address if assigned | +| ssl_enabled | Whether SSL is enabled | +| username | Database admin username | + +#### Relationships + +- Database instances belong to a cluster. + + ```cypher + (:E2eDatabaseCluster)-[:HAS_INSTANCE]->(:E2eDatabaseInstance) + ``` diff --git a/prowler/providers/e2e/e2e_provider.py b/prowler/providers/e2e/e2e_provider.py new file mode 100644 index 00000000000..be33875db13 --- /dev/null +++ b/prowler/providers/e2e/e2e_provider.py @@ -0,0 +1,259 @@ +import os + +import requests +from colorama import Style + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.e2e.exceptions.exceptions import ( + E2eCredentialsError, + E2eSessionError, +) +from prowler.providers.e2e.lib.mutelist.mutelist import E2eMutelist +from prowler.providers.e2e.models import ( + E2E_DEFAULT_LOCATIONS, + E2eIdentityInfo, + E2eSession, +) + + +class E2eProvider(Provider): + """Provider class for E2E Cloud.""" + + _type: str = "e2e" + _session: E2eSession + _identity: E2eIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: E2eMutelist + audit_metadata: Audit_Metadata + + def __init__( + self, + api_key: str = None, + auth_token: str = None, + project_id: str | int = None, + locations: list[str] | None = None, + config_path: str = None, + fixer_config: dict = None, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + logger.info("Initializing E2E Cloud Provider...") + + self._api_key = api_key or os.getenv("E2E_API_KEY") + self._auth_token = auth_token or os.getenv("E2E_AUTH_TOKEN") + project_value = project_id or os.getenv("E2E_PROJECT_ID") + self._project_id = int(project_value) if project_value else None + self._locations = self._resolve_locations(locations) + + if not self._api_key or not self._auth_token or self._project_id is None: + raise E2eCredentialsError( + "E2eProvider requires api_key, auth_token, and project_id." + ) + + self._fixer_config = fixer_config if fixer_config else {} + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + if mutelist_content is not None: + self._mutelist = E2eMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self._type) + self._mutelist = E2eMutelist(mutelist_path=mutelist_path) + + self._session = E2eProvider.setup_session( + api_key=self._api_key, + auth_token=self._auth_token, + project_id=self._project_id, + locations=self._locations, + ) + self._identity = E2eIdentityInfo( + project_id=self._project_id, + locations=self._locations, + ) + + Provider.set_global_provider(self) + + @staticmethod + def _resolve_locations(locations: list[str] | None) -> list[str]: + """Resolve scan locations from CLI args, env vars, or defaults. + + Args: + locations: Optional list of location names from CLI arguments. + + Returns: + The resolved list of E2E Cloud locations to scan. + """ + if locations: + return locations + + env_location = os.getenv("E2E_LOCATION") + if env_location: + return [env_location] + + return list(E2E_DEFAULT_LOCATIONS) + + @property + def type(self) -> str: + return self._type + + @property + def session(self) -> E2eSession: + return self._session + + @property + def identity(self) -> E2eIdentityInfo: + return self._identity + + @property + def audit_config(self) -> dict: + return self._audit_config + + @property + def fixer_config(self) -> dict: + return self._fixer_config + + @property + def mutelist(self) -> E2eMutelist: + return self._mutelist + + @staticmethod + def setup_session( + api_key: str, + auth_token: str, + project_id: int, + locations: list[str], + ) -> E2eSession: + """Create an authenticated E2E Cloud API session. + + Args: + api_key: E2E Cloud API key. + auth_token: Bearer auth token for the MyAccount API. + project_id: E2E Cloud project identifier. + locations: Locations included in the session scope. + + Returns: + A configured E2eSession with an HTTP client. + + Raises: + E2eSessionError: If session initialization fails. + """ + try: + http_session = requests.Session() + http_session.headers.update( + { + "Authorization": f"Bearer {auth_token}", + "Content-Type": "application/json", + } + ) + return E2eSession( + api_key=api_key, + auth_token=auth_token, + project_id=project_id, + locations=locations, + http_session=http_session, + ) + except Exception as error: + raise E2eSessionError( + message="Failed to initialize E2E Cloud session.", + original_exception=error, + ) from error + + def print_credentials(self) -> None: + """Print masked E2E Cloud credentials and scan scope to stdout.""" + masked_key = ( + f"{self._api_key[:4]}...{self._api_key[-4:]}" + if self._api_key and len(self._api_key) > 8 + else "****" + ) + report_lines = [ + f" Project ID: {self._project_id}", + f" Locations: {', '.join(self._locations)}", + f" API Key: {masked_key}", + ] + report_title = ( + f"{Style.BRIGHT}Using the E2E Cloud credentials below:{Style.RESET_ALL}" + ) + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + api_key: str = None, + auth_token: str = None, + project_id: str | int = None, + locations: list[str] | None = None, + raise_on_exception: bool = True, + ) -> Connection: + """Test connectivity to the E2E Cloud MyAccount API. + + Args: + api_key: E2E Cloud API key. Falls back to E2E_API_KEY. + auth_token: Bearer auth token. Falls back to E2E_AUTH_TOKEN. + project_id: Project identifier. Falls back to E2E_PROJECT_ID. + locations: Optional locations to use for the probe request. + raise_on_exception: Whether to re-raise caught exceptions. + + Returns: + Connection indicating success or containing the error. + + Raises: + E2eCredentialsError: If required credentials are missing. + E2eSessionError: If the API returns a non-200 response. + Exception: Any unexpected error when raise_on_exception is True. + """ + try: + api_key = api_key or os.getenv("E2E_API_KEY") + auth_token = auth_token or os.getenv("E2E_AUTH_TOKEN") + project_value = project_id or os.getenv("E2E_PROJECT_ID") + project_id_int = int(project_value) if project_value else None + resolved_locations = locations or ( + [os.getenv("E2E_LOCATION")] + if os.getenv("E2E_LOCATION") + else list(E2E_DEFAULT_LOCATIONS) + ) + + if not api_key or not auth_token or project_id_int is None: + raise E2eCredentialsError( + "E2E Cloud test_connection requires api_key, auth_token, and project_id." + ) + + session = E2eProvider.setup_session( + api_key=api_key, + auth_token=auth_token, + project_id=project_id_int, + locations=resolved_locations, + ) + response = session.http_session.get( + f"{session.base_url}/nodes/", + params={ + "apikey": session.api_key, + "project_id": session.project_id, + "location": resolved_locations[0], + }, + timeout=30, + ) + if response.status_code != 200: + error_msg = ( + f"E2E Cloud connection failed with status {response.status_code}: " + f"{response.text}" + ) + raise E2eSessionError(message=error_msg) + + return Connection(is_connected=True) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise error + return Connection(error=error) diff --git a/prowler/providers/e2e/exceptions/__init__.py b/prowler/providers/e2e/exceptions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/exceptions/exceptions.py b/prowler/providers/e2e/exceptions/exceptions.py new file mode 100644 index 00000000000..01411b4b011 --- /dev/null +++ b/prowler/providers/e2e/exceptions/exceptions.py @@ -0,0 +1,24 @@ +class E2eCredentialsError(Exception): + """Raised when E2E Cloud credentials are missing or invalid.""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class E2eSessionError(Exception): + """Raised when the E2E Cloud session cannot be initialized.""" + + def __init__(self, message: str, original_exception: Exception | None = None): + self.message = message + self.original_exception = original_exception + super().__init__(message) + + +class E2eAPIError(Exception): + """Raised when an E2E Cloud API request fails.""" + + def __init__(self, message: str, original_exception: Exception | None = None): + self.message = message + self.original_exception = original_exception + super().__init__(message) diff --git a/prowler/providers/e2e/lib/__init__.py b/prowler/providers/e2e/lib/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/lib/api/__init__.py b/prowler/providers/e2e/lib/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/lib/api/client.py b/prowler/providers/e2e/lib/api/client.py new file mode 100644 index 00000000000..12f274aebb5 --- /dev/null +++ b/prowler/providers/e2e/lib/api/client.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any + +import requests + +from prowler.lib.logger import logger +from prowler.providers.e2e.exceptions.exceptions import E2eAPIError +from prowler.providers.e2e.models import E2eSession + + +class E2eAPIClient: + """Shared HTTP client for E2E Cloud MyAccount API requests.""" + + def __init__(self, session: E2eSession): + self.session = session + + def _auth_params(self, location: str, extra: dict | None = None) -> dict[str, Any]: + params = { + "apikey": self.session.api_key, + "project_id": self.session.project_id, + "location": location, + } + if extra: + params.update(extra) + return params + + def get( + self, + path: str, + location: str, + params: dict | None = None, + timeout: int = 30, + ) -> dict: + """Perform a GET request against the E2E Cloud API.""" + url = f"{self.session.base_url}{path}" + query_params = self._auth_params(location, params) + try: + response = self.session.http_session.get( + url, + params=query_params, + timeout=timeout, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + return {"data": payload} + return payload + except requests.exceptions.RequestException as error: + logger.error( + f"E2E API GET {path} failed: {error.__class__.__name__}: {error}" + ) + raise E2eAPIError( + message=f"GET {path} failed for location {location}", + original_exception=error, + ) from error + + def get_data( + self, + path: str, + location: str, + params: dict | None = None, + ) -> list | dict: + """Return the `data` field from a standard E2E API envelope.""" + payload = self.get(path, location=location, params=params) + return payload.get("data", []) + + def paginate( + self, + path: str, + location: str, + params: dict | None = None, + per_page: int = 100, + ) -> list: + """Iterate page_no/per_page style paginated list endpoints.""" + all_items: list = [] + page_no = 1 + total_pages = 1 + + while page_no <= total_pages: + page_params = {"page_no": page_no, "per_page": per_page} + if params: + page_params.update(params) + + payload = self.get(path, location=location, params=page_params) + data = payload.get("data", []) + if isinstance(data, list): + all_items.extend(data) + elif isinstance(data, dict): + all_items.extend(data.values()) + + total_pages = int(payload.get("total_page_number", page_no)) + if not data: + break + page_no += 1 + + return all_items diff --git a/prowler/providers/e2e/lib/arguments/__init__.py b/prowler/providers/e2e/lib/arguments/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/lib/arguments/arguments.py b/prowler/providers/e2e/lib/arguments/arguments.py new file mode 100644 index 00000000000..8a745123abe --- /dev/null +++ b/prowler/providers/e2e/lib/arguments/arguments.py @@ -0,0 +1,66 @@ +import os + +SENSITIVE_ARGUMENTS = frozenset({"--e2e-api-key", "--e2e-auth-token"}) + + +def init_parser(self): + """Init the E2E Cloud Provider CLI parser.""" + e2e_parser = self.subparsers.add_parser( + "e2e", + parents=[self.common_providers_parser], + help="E2E Cloud Provider", + ) + + auth_group = e2e_parser.add_argument_group("Authentication") + auth_group.add_argument( + "--e2e-api-key", + nargs="?", + default=None, + metavar="E2E_API_KEY", + help="E2E Cloud API key. Use E2E_API_KEY env var instead of passing directly.", + ) + auth_group.add_argument( + "--e2e-auth-token", + nargs="?", + default=None, + metavar="E2E_AUTH_TOKEN", + help="E2E Cloud auth token. Use E2E_AUTH_TOKEN env var instead of passing directly.", + ) + auth_group.add_argument( + "--e2e-project-id", + nargs="?", + default=None, + metavar="E2E_PROJECT_ID", + help="E2E Cloud project ID. Use E2E_PROJECT_ID env var instead of passing directly.", + ) + + scope_group = e2e_parser.add_argument_group("Scope") + scope_group.add_argument( + "--e2e-location", + "--e2e-locations", + nargs="*", + default=None, + metavar="LOCATION", + help="E2E Cloud region(s) to scan (e.g. Delhi Chennai). Defaults to E2E_LOCATION or Delhi.", + ) + + +def validate_arguments(arguments) -> tuple[bool, str]: + """Validate E2E Cloud provider CLI arguments.""" + api_key = arguments.e2e_api_key or os.getenv("E2E_API_KEY") + auth_token = arguments.e2e_auth_token or os.getenv("E2E_AUTH_TOKEN") + project_id = arguments.e2e_project_id or os.getenv("E2E_PROJECT_ID") + + if not api_key: + return False, "E2E Cloud provider requires an API key (E2E_API_KEY)." + if not auth_token: + return False, "E2E Cloud provider requires an auth token (E2E_AUTH_TOKEN)." + if not project_id: + return False, "E2E Cloud provider requires a project ID (E2E_PROJECT_ID)." + + try: + int(project_id) + except (TypeError, ValueError): + return False, "E2E Cloud project ID must be an integer." + + return True, "" diff --git a/prowler/providers/e2e/lib/mutelist/__init__.py b/prowler/providers/e2e/lib/mutelist/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/lib/mutelist/mutelist.py b/prowler/providers/e2e/lib/mutelist/mutelist.py new file mode 100644 index 00000000000..135435fbc88 --- /dev/null +++ b/prowler/providers/e2e/lib/mutelist/mutelist.py @@ -0,0 +1,27 @@ +"""E2E Cloud mutelist support for suppressing findings.""" + +from prowler.lib.check.models import CheckReportE2e +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class E2eMutelist(Mutelist): + """Mutelist implementation for E2E Cloud check findings.""" + + def is_finding_muted(self, **kwargs) -> bool: + """Determine whether an E2E Cloud finding is muted. + + Args: + **kwargs: Keyword arguments; must include ``finding``. + + Returns: + True if the finding matches a mutelist entry, otherwise False. + """ + finding: CheckReportE2e = kwargs["finding"] + return self.is_muted( + finding.resource_id, + finding.check_metadata.CheckID, + finding.location, + finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/e2e/lib/service/__init__.py b/prowler/providers/e2e/lib/service/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/lib/service/service.py b/prowler/providers/e2e/lib/service/service.py new file mode 100644 index 00000000000..1b7e792cb3c --- /dev/null +++ b/prowler/providers/e2e/lib/service/service.py @@ -0,0 +1,19 @@ +from prowler.providers.e2e.e2e_provider import E2eProvider +from prowler.providers.e2e.lib.api.client import E2eAPIClient + + +class E2eService: + """Base class for E2E Cloud services.""" + + def __init__(self, service: str, provider: E2eProvider): + """Initialize an E2E Cloud service client. + + Args: + service: Service name used for logging and configuration lookup. + provider: The active E2E Cloud provider instance. + """ + self.provider = provider + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() if not service.islower() else service + self.client = E2eAPIClient(provider.session) diff --git a/prowler/providers/e2e/models.py b/prowler/providers/e2e/models.py new file mode 100644 index 00000000000..27f9f38fe30 --- /dev/null +++ b/prowler/providers/e2e/models.py @@ -0,0 +1,55 @@ +from typing import Any + +from pydantic import BaseModel, Field + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + +E2E_DEFAULT_LOCATIONS = ("Delhi", "Chennai") +E2E_BASE_URL = "https://api.e2enetworks.com/myaccount/api/v1" + + +class E2eSession(BaseModel): + """E2E Cloud API session information.""" + + api_key: str = Field(exclude=True, repr=False) + auth_token: str = Field(exclude=True, repr=False) + project_id: int + locations: list[str] + base_url: str = E2E_BASE_URL + http_session: Any = Field(default=None, exclude=True) + + +class E2eIdentityInfo(BaseModel): + """E2E Cloud identity and scoping information.""" + + project_id: int + locations: list[str] + + +class E2eOutputOptions(ProviderOutputOptions): + """Customize output filenames for E2E Cloud scans.""" + + def __init__( + self, + arguments: object, + bulk_checks_metadata: dict, + identity: E2eIdentityInfo, + ) -> None: + """Initialize E2E Cloud output options. + + Args: + arguments: Parsed CLI arguments for the scan. + bulk_checks_metadata: Loaded metadata for all checks in the scan. + identity: E2E Cloud identity information used in output filenames. + """ + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + self.output_filename = ( + f"prowler-output-e2e-{identity.project_id}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/e2e/services/__init__.py b/prowler/providers/e2e/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/__init__.py b/prowler/providers/e2e/services/database/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/database_client.py b/prowler/providers/e2e/services/database/database_client.py new file mode 100644 index 00000000000..b9cff1a32a1 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.e2e.services.database.database_service import Database + +database_client = Database(Provider.get_global_provider()) diff --git a/prowler/providers/e2e/services/database/database_cluster_backup_enabled/__init__.py b/prowler/providers/e2e/services/database/database_cluster_backup_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/database_cluster_backup_enabled/database_cluster_backup_enabled.metadata.json b/prowler/providers/e2e/services/database/database_cluster_backup_enabled/database_cluster_backup_enabled.metadata.json new file mode 100644 index 00000000000..fb1233c2af8 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_backup_enabled/database_cluster_backup_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "database_cluster_backup_enabled", + "CheckTitle": "Check if E2E Cloud database clusters have backups enabled", + "CheckType": [], + "ServiceName": "database", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "database", + "Description": "Check if E2E Cloud database clusters have backups enabled", + "Risk": "Database clusters without backups are vulnerable to data loss from failures or accidental deletion.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/rds/clusters//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"backup_enabled\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud database clusters have backups enabled", + "Url": "https://hub.prowler.com/check/database_cluster_backup_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/database/rds/" + ] +} diff --git a/prowler/providers/e2e/services/database/database_cluster_backup_enabled/database_cluster_backup_enabled.py b/prowler/providers/e2e/services/database/database_cluster_backup_enabled/database_cluster_backup_enabled.py new file mode 100644 index 00000000000..4db2ef83ca8 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_backup_enabled/database_cluster_backup_enabled.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.database.database_client import database_client + + +class database_cluster_backup_enabled(Check): + """Check if E2E Cloud database clusters have backups enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for cluster in database_client.clusters: + report = CheckReportE2e(metadata=self.metadata(), resource=cluster) + report.status = "PASS" + report.status_extended = ( + f"Database cluster {cluster.name} has backups enabled." + ) + if not cluster.backup_enabled: + report.status = "FAIL" + report.status_extended = ( + f"Database cluster {cluster.name} does not have backups enabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/database/database_cluster_default_admin_username/__init__.py b/prowler/providers/e2e/services/database/database_cluster_default_admin_username/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/database_cluster_default_admin_username/database_cluster_default_admin_username.metadata.json b/prowler/providers/e2e/services/database/database_cluster_default_admin_username/database_cluster_default_admin_username.metadata.json new file mode 100644 index 00000000000..ce2ede0ec67 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_default_admin_username/database_cluster_default_admin_username.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "database_cluster_default_admin_username", + "CheckTitle": "Check if E2E Cloud database clusters do not use the default admin username", + "CheckType": [], + "ServiceName": "database", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "database", + "Description": "Check if E2E Cloud database clusters do not use the default admin username", + "Risk": "Default admin usernames increase exposure to credential guessing and brute-force attacks against database clusters.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/rds/clusters//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"username\":\"\"}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud database clusters do not use the default admin username", + "Url": "https://hub.prowler.com/check/database_cluster_default_admin_username" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/database/rds/" + ] +} diff --git a/prowler/providers/e2e/services/database/database_cluster_default_admin_username/database_cluster_default_admin_username.py b/prowler/providers/e2e/services/database/database_cluster_default_admin_username/database_cluster_default_admin_username.py new file mode 100644 index 00000000000..52b8797336c --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_default_admin_username/database_cluster_default_admin_username.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.database.database_client import database_client + + +class database_cluster_default_admin_username(Check): + """Check if E2E Cloud database clusters do not use the default admin username.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for cluster in database_client.clusters: + report = CheckReportE2e(metadata=self.metadata(), resource=cluster) + report.status = "PASS" + report.status_extended = f"Database cluster {cluster.name} does not use the default admin username." + if cluster.master_username.lower() == "admin": + report.status = "FAIL" + report.status_extended = ( + f"Database cluster {cluster.name} uses the default admin username." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/database/database_cluster_ip_whitelist_configured/__init__.py b/prowler/providers/e2e/services/database/database_cluster_ip_whitelist_configured/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/database_cluster_ip_whitelist_configured/database_cluster_ip_whitelist_configured.metadata.json b/prowler/providers/e2e/services/database/database_cluster_ip_whitelist_configured/database_cluster_ip_whitelist_configured.metadata.json new file mode 100644 index 00000000000..5b38627ae7d --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_ip_whitelist_configured/database_cluster_ip_whitelist_configured.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "database_cluster_ip_whitelist_configured", + "CheckTitle": "Check if E2E Cloud database clusters with public IPs have IP whitelisting configured", + "CheckType": [], + "ServiceName": "database", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "database", + "Description": "Check if E2E Cloud database clusters with public IPs have IP whitelisting configured", + "Risk": "Database clusters without IP whitelisting allow broader network access and increase unauthorized connection risk.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/rds/clusters//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"whitelist_ips\":[\"\"]}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud database clusters with public IPs have IP whitelisting configured", + "Url": "https://hub.prowler.com/check/database_cluster_ip_whitelist_configured" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/database/rds/" + ] +} diff --git a/prowler/providers/e2e/services/database/database_cluster_ip_whitelist_configured/database_cluster_ip_whitelist_configured.py b/prowler/providers/e2e/services/database/database_cluster_ip_whitelist_configured/database_cluster_ip_whitelist_configured.py new file mode 100644 index 00000000000..b43332ace64 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_ip_whitelist_configured/database_cluster_ip_whitelist_configured.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.database.database_client import database_client + + +class database_cluster_ip_whitelist_configured(Check): + """Check if E2E Cloud database clusters with public IPs have IP whitelisting configured.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for cluster in database_client.clusters: + if not cluster.master_has_public_ip: + continue + report = CheckReportE2e(metadata=self.metadata(), resource=cluster) + report.status = "PASS" + report.status_extended = ( + f"Database cluster {cluster.name} has IP whitelisting configured." + ) + if not cluster.whitelisted_ips: + report.status = "FAIL" + report.status_extended = f"Database cluster {cluster.name} has a public IP but no whitelisted IPs." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/database/database_cluster_public_ip_not_assigned/__init__.py b/prowler/providers/e2e/services/database/database_cluster_public_ip_not_assigned/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/database_cluster_public_ip_not_assigned/database_cluster_public_ip_not_assigned.metadata.json b/prowler/providers/e2e/services/database/database_cluster_public_ip_not_assigned/database_cluster_public_ip_not_assigned.metadata.json new file mode 100644 index 00000000000..2811b1389b7 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_public_ip_not_assigned/database_cluster_public_ip_not_assigned.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "database_cluster_public_ip_not_assigned", + "CheckTitle": "Check if E2E Cloud database clusters do not expose a public IP on the master node", + "CheckType": [], + "ServiceName": "database", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "database", + "Description": "Check if E2E Cloud database clusters do not expose a public IP on the master node", + "Risk": "Public database endpoints expand attack surface and increase risk of unauthorized access over the internet.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/rds/clusters//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"public_ip\":null}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud database clusters do not expose a public IP on the master node", + "Url": "https://hub.prowler.com/check/database_cluster_public_ip_not_assigned" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/database/rds/" + ] +} diff --git a/prowler/providers/e2e/services/database/database_cluster_public_ip_not_assigned/database_cluster_public_ip_not_assigned.py b/prowler/providers/e2e/services/database/database_cluster_public_ip_not_assigned/database_cluster_public_ip_not_assigned.py new file mode 100644 index 00000000000..2fdfa273c5f --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_public_ip_not_assigned/database_cluster_public_ip_not_assigned.py @@ -0,0 +1,18 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.database.database_client import database_client + + +class database_cluster_public_ip_not_assigned(Check): + """Check if E2E Cloud database clusters do not expose a public IP on the master node.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for cluster in database_client.clusters: + report = CheckReportE2e(metadata=self.metadata(), resource=cluster) + report.status = "PASS" + report.status_extended = f"Database cluster {cluster.name} master node does not have a public IP." + if cluster.master_has_public_ip: + report.status = "FAIL" + report.status_extended = f"Database cluster {cluster.name} master node has a public IP assigned." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/database/database_cluster_running/__init__.py b/prowler/providers/e2e/services/database/database_cluster_running/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/database_cluster_running/database_cluster_running.metadata.json b/prowler/providers/e2e/services/database/database_cluster_running/database_cluster_running.metadata.json new file mode 100644 index 00000000000..a80979365e1 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_running/database_cluster_running.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "database_cluster_running", + "CheckTitle": "Check if E2E Cloud database clusters are in RUNNING status", + "CheckType": [], + "ServiceName": "database", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "database", + "Description": "Check if E2E Cloud database clusters are in RUNNING status", + "Risk": "Non-running database clusters may indicate outages or misconfiguration that impacts availability and recovery.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X POST -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" \"https://api.e2enetworks.com/myaccount/api/v1/rds/clusters//start/?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\"", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud database clusters are in RUNNING status", + "Url": "https://hub.prowler.com/check/database_cluster_running" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/database/rds/" + ] +} diff --git a/prowler/providers/e2e/services/database/database_cluster_running/database_cluster_running.py b/prowler/providers/e2e/services/database/database_cluster_running/database_cluster_running.py new file mode 100644 index 00000000000..6f0e7f3b891 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_running/database_cluster_running.py @@ -0,0 +1,18 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.database.database_client import database_client + + +class database_cluster_running(Check): + """Check if E2E Cloud database clusters are in RUNNING status.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for cluster in database_client.clusters: + report = CheckReportE2e(metadata=self.metadata(), resource=cluster) + report.status = "PASS" + report.status_extended = f"Database cluster {cluster.name} is running." + if cluster.status != "RUNNING": + report.status = "FAIL" + report.status_extended = f"Database cluster {cluster.name} is not running (status: {cluster.status})." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/database/database_cluster_ssl_enabled/__init__.py b/prowler/providers/e2e/services/database/database_cluster_ssl_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/database_cluster_ssl_enabled/database_cluster_ssl_enabled.metadata.json b/prowler/providers/e2e/services/database/database_cluster_ssl_enabled/database_cluster_ssl_enabled.metadata.json new file mode 100644 index 00000000000..1b7620962f7 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_ssl_enabled/database_cluster_ssl_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "database_cluster_ssl_enabled", + "CheckTitle": "Check if E2E Cloud database clusters have SSL enabled on the master node", + "CheckType": [], + "ServiceName": "database", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "database", + "Description": "Check if E2E Cloud database clusters have SSL enabled on the master node", + "Risk": "Database clusters without SSL expose credentials and data in transit to interception.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/rds/clusters//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_ssl_enabled\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud database clusters have SSL enabled on the master node", + "Url": "https://hub.prowler.com/check/database_cluster_ssl_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/database/rds/" + ] +} diff --git a/prowler/providers/e2e/services/database/database_cluster_ssl_enabled/database_cluster_ssl_enabled.py b/prowler/providers/e2e/services/database/database_cluster_ssl_enabled/database_cluster_ssl_enabled.py new file mode 100644 index 00000000000..e407019535c --- /dev/null +++ b/prowler/providers/e2e/services/database/database_cluster_ssl_enabled/database_cluster_ssl_enabled.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.database.database_client import database_client + + +class database_cluster_ssl_enabled(Check): + """Check if E2E Cloud database clusters have SSL enabled on the master node.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for cluster in database_client.clusters: + report = CheckReportE2e(metadata=self.metadata(), resource=cluster) + report.status = "PASS" + report.status_extended = ( + f"Database cluster {cluster.name} has SSL enabled on the master node." + ) + if not cluster.master_ssl_enabled: + report.status = "FAIL" + report.status_extended = f"Database cluster {cluster.name} does not have SSL enabled on the master node." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/database/database_replica_public_ip_not_assigned/__init__.py b/prowler/providers/e2e/services/database/database_replica_public_ip_not_assigned/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/database/database_replica_public_ip_not_assigned/database_replica_public_ip_not_assigned.metadata.json b/prowler/providers/e2e/services/database/database_replica_public_ip_not_assigned/database_replica_public_ip_not_assigned.metadata.json new file mode 100644 index 00000000000..27bb4abf146 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_replica_public_ip_not_assigned/database_replica_public_ip_not_assigned.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "database_replica_public_ip_not_assigned", + "CheckTitle": "Check if E2E Cloud database read replicas do not have a public IP assigned", + "CheckType": [], + "ServiceName": "database", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "database", + "Description": "Check if E2E Cloud database read replicas do not have a public IP assigned", + "Risk": "Read replicas with public IPs increase database exposure and unauthorized access risk.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/rds/replicas//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"public_ip\":null}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud database read replicas do not have a public IP assigned", + "Url": "https://hub.prowler.com/check/database_replica_public_ip_not_assigned" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/database/rds/" + ] +} diff --git a/prowler/providers/e2e/services/database/database_replica_public_ip_not_assigned/database_replica_public_ip_not_assigned.py b/prowler/providers/e2e/services/database/database_replica_public_ip_not_assigned/database_replica_public_ip_not_assigned.py new file mode 100644 index 00000000000..33b59276e70 --- /dev/null +++ b/prowler/providers/e2e/services/database/database_replica_public_ip_not_assigned/database_replica_public_ip_not_assigned.py @@ -0,0 +1,24 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.database.database_client import database_client + + +class database_replica_public_ip_not_assigned(Check): + """Check if E2E Cloud database read replicas do not have a public IP assigned.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for instance in database_client.instances: + if instance.role != "replica": + continue + report = CheckReportE2e(metadata=self.metadata(), resource=instance) + report.status = "PASS" + report.status_extended = ( + f"Database replica {instance.name} does not have a public IP assigned." + ) + if instance.has_public_ip: + report.status = "FAIL" + report.status_extended = ( + f"Database replica {instance.name} has a public IP assigned." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/database/database_service.py b/prowler/providers/e2e/services/database/database_service.py new file mode 100644 index 00000000000..42eca29d83b --- /dev/null +++ b/prowler/providers/e2e/services/database/database_service.py @@ -0,0 +1,164 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.e2e.lib.service.service import E2eService + + +def _has_public_ip(public_ip_address: str | None) -> bool: + if not public_ip_address: + return False + value = str(public_ip_address).strip() + if not value or value.lower() in ("[]", "null", "none"): + return False + return True + + +class Database(E2eService): + """Service class for E2E Cloud DBaaS (RDS) resources.""" + + def __init__(self, provider): + super().__init__("database", provider) + self.clusters: list[DatabaseCluster] = [] + self.instances: list[DatabaseInstance] = [] + self._fetch_clusters() + + def _fetch_clusters(self): + for location in self.provider.session.locations: + try: + cluster_list = self.client.get_data("/rds/cluster/", location=location) + if not isinstance(cluster_list, list): + continue + + for item in cluster_list: + cluster_id = str(item.get("id", "")) + detail = self._get_cluster_detail(cluster_id, location) + merged = {**item, **detail} + + master_node = merged.get("master_node", {}) or {} + database_info = master_node.get("database", {}) or {} + software = ( + merged.get("software", {}) + or master_node.get("plan", {}).get("software", {}) + or {} + ) + + cluster = DatabaseCluster( + id=cluster_id, + name=merged.get("name", ""), + location=location, + status=merged.get("status", ""), + software_name=software.get("name", ""), + software_version=software.get("version", ""), + backup_enabled=bool(merged.get("backup_enabled", False)), + whitelisted_ips=merged.get("whitelisted_ips", []) or [], + master_ssl_enabled=bool(master_node.get("ssl", False)), + master_public_ip=master_node.get("public_ip_address"), + master_username=database_info.get("username", ""), + master_has_public_ip=_has_public_ip( + master_node.get("public_ip_address") + ), + ) + self.clusters.append(cluster) + + self.instances.append( + DatabaseInstance( + id=str(master_node.get("instance_id", cluster_id)), + name=master_node.get("node_name", merged.get("name", "")), + cluster_id=cluster_id, + cluster_name=cluster.name, + location=location, + role="master", + public_ip_address=master_node.get("public_ip_address"), + has_public_ip=_has_public_ip( + master_node.get("public_ip_address") + ), + ssl_enabled=bool(master_node.get("ssl", False)), + username=database_info.get("username", ""), + ) + ) + + for slave in merged.get("slave_nodes", []) or []: + if not isinstance(slave, dict): + continue + slave_db = slave.get("database", {}) or {} + self.instances.append( + DatabaseInstance( + id=str(slave.get("instance_id", "")), + name=slave.get("node_name", ""), + cluster_id=cluster_id, + cluster_name=cluster.name, + location=location, + role="replica", + public_ip_address=slave.get("public_ip_address"), + has_public_ip=_has_public_ip( + slave.get("public_ip_address") + ), + ssl_enabled=bool(slave.get("ssl", False)), + username=slave_db.get("username", ""), + ) + ) + except Exception as error: + logger.error( + f"database - Error fetching clusters in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_cluster_detail(self, cluster_id: str, location: str) -> dict: + if not cluster_id: + return {} + try: + data = self.client.get_data( + f"/rds/cluster/{cluster_id}/", + location=location, + ) + return data if isinstance(data, dict) else {} + except Exception as error: + logger.error( + f"database - Error fetching cluster detail {cluster_id} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + +class DatabaseCluster(BaseModel): + id: str + name: str + location: str + status: str = "" + software_name: str = "" + software_version: str = "" + backup_enabled: bool = False + whitelisted_ips: list = [] + master_ssl_enabled: bool = False + master_public_ip: str | None = None + master_username: str = "" + master_has_public_ip: bool = False + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name + + +class DatabaseInstance(BaseModel): + id: str + name: str + cluster_id: str + cluster_name: str + location: str + role: str + public_ip_address: str | None = None + has_public_ip: bool = False + ssl_enabled: bool = False + username: str = "" + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name diff --git a/prowler/providers/e2e/services/loadbalancer/__init__.py b/prowler/providers/e2e/services/loadbalancer/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_alb_https_uses_ssl_certificate/__init__.py b/prowler/providers/e2e/services/loadbalancer/loadbalancer_alb_https_uses_ssl_certificate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_alb_https_uses_ssl_certificate/loadbalancer_alb_https_uses_ssl_certificate.metadata.json b/prowler/providers/e2e/services/loadbalancer/loadbalancer_alb_https_uses_ssl_certificate/loadbalancer_alb_https_uses_ssl_certificate.metadata.json new file mode 100644 index 00000000000..1ec896e3860 --- /dev/null +++ b/prowler/providers/e2e/services/loadbalancer/loadbalancer_alb_https_uses_ssl_certificate/loadbalancer_alb_https_uses_ssl_certificate.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "loadbalancer_alb_https_uses_ssl_certificate", + "CheckTitle": "Check if E2E Cloud ALB/HTTPS load balancers use an SSL certificate", + "CheckType": [], + "ServiceName": "loadbalancer", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "LoadBalancer", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud application load balancers serving HTTPS traffic have an SSL certificate configured.", + "Risk": "HTTPS load balancers without a valid SSL certificate expose traffic to interception and downgrade attacks, weakening confidentiality and integrity for client connections.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/appliances//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"ssl_context\":{\"ssl_certificate_id\":}}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach a valid SSL certificate to HTTPS load balancers.", + "Url": "https://hub.prowler.com/check/loadbalancer_alb_https_uses_ssl_certificate" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/load-balancer/" + ] +} diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_alb_https_uses_ssl_certificate/loadbalancer_alb_https_uses_ssl_certificate.py b/prowler/providers/e2e/services/loadbalancer/loadbalancer_alb_https_uses_ssl_certificate/loadbalancer_alb_https_uses_ssl_certificate.py new file mode 100644 index 00000000000..7ea6dad2ef3 --- /dev/null +++ b/prowler/providers/e2e/services/loadbalancer/loadbalancer_alb_https_uses_ssl_certificate/loadbalancer_alb_https_uses_ssl_certificate.py @@ -0,0 +1,25 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.loadbalancer.loadbalancer_client import ( + loadbalancer_client, +) + + +class loadbalancer_alb_https_uses_ssl_certificate(Check): + """Ensure HTTPS load balancers have an SSL certificate configured.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for lb in loadbalancer_client.load_balancers: + if not lb.is_alb_https: + continue + + report = CheckReportE2e(metadata=self.metadata(), resource=lb) + report.status = "PASS" + report.status_extended = ( + f"Load balancer {lb.name} uses an SSL certificate for HTTPS traffic." + ) + if not lb.ssl_certificate_id: + report.status = "FAIL" + report.status_extended = f"Load balancer {lb.name} does not have an SSL certificate configured for HTTPS traffic." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_backend_health_check_enabled/__init__.py b/prowler/providers/e2e/services/loadbalancer/loadbalancer_backend_health_check_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_backend_health_check_enabled/loadbalancer_backend_health_check_enabled.metadata.json b/prowler/providers/e2e/services/loadbalancer/loadbalancer_backend_health_check_enabled/loadbalancer_backend_health_check_enabled.metadata.json new file mode 100644 index 00000000000..3e651ce9bc9 --- /dev/null +++ b/prowler/providers/e2e/services/loadbalancer/loadbalancer_backend_health_check_enabled/loadbalancer_backend_health_check_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "loadbalancer_backend_health_check_enabled", + "CheckTitle": "Check if E2E Cloud ALB load balancers have backend health checks enabled", + "CheckType": [], + "ServiceName": "loadbalancer", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "LoadBalancer", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud application load balancers have HTTP health checks configured for backends.", + "Risk": "Load balancers without backend health checks may route traffic to failed or unhealthy nodes, causing outages and masking active compromise on degraded backends.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/appliances//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"backends\":[{\"http_check\":true}]}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure HTTP health checks for ALB backends.", + "Url": "https://hub.prowler.com/check/loadbalancer_backend_health_check_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/load-balancer/" + ] +} diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_backend_health_check_enabled/loadbalancer_backend_health_check_enabled.py b/prowler/providers/e2e/services/loadbalancer/loadbalancer_backend_health_check_enabled/loadbalancer_backend_health_check_enabled.py new file mode 100644 index 00000000000..a650072409e --- /dev/null +++ b/prowler/providers/e2e/services/loadbalancer/loadbalancer_backend_health_check_enabled/loadbalancer_backend_health_check_enabled.py @@ -0,0 +1,25 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.loadbalancer.loadbalancer_client import ( + loadbalancer_client, +) + + +class loadbalancer_backend_health_check_enabled(Check): + """Ensure ALB load balancers have backend health checks configured.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for lb in loadbalancer_client.load_balancers: + if not lb.is_alb: + continue + + report = CheckReportE2e(metadata=self.metadata(), resource=lb) + report.status = "PASS" + report.status_extended = ( + f"Load balancer {lb.name} has backend health checks configured." + ) + if not lb.has_backend_health_check: + report.status = "FAIL" + report.status_extended = f"Load balancer {lb.name} does not have backend health checks configured." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_bitninja_enabled/__init__.py b/prowler/providers/e2e/services/loadbalancer/loadbalancer_bitninja_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_bitninja_enabled/loadbalancer_bitninja_enabled.metadata.json b/prowler/providers/e2e/services/loadbalancer/loadbalancer_bitninja_enabled/loadbalancer_bitninja_enabled.metadata.json new file mode 100644 index 00000000000..c73f10caacb --- /dev/null +++ b/prowler/providers/e2e/services/loadbalancer/loadbalancer_bitninja_enabled/loadbalancer_bitninja_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "loadbalancer_bitninja_enabled", + "CheckTitle": "Check if E2E Cloud load balancers have BitNinja protection enabled", + "CheckType": [], + "ServiceName": "loadbalancer", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "LoadBalancer", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud load balancers have BitNinja protection enabled.", + "Risk": "Load balancers without BitNinja protection have reduced web application firewall coverage, increasing exposure to automated attacks and malicious traffic against public endpoints.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/appliances//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"enable_bitninja\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable BitNinja protection on load balancer appliances.", + "Url": "https://hub.prowler.com/check/loadbalancer_bitninja_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/load-balancer/" + ] +} diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_bitninja_enabled/loadbalancer_bitninja_enabled.py b/prowler/providers/e2e/services/loadbalancer/loadbalancer_bitninja_enabled/loadbalancer_bitninja_enabled.py new file mode 100644 index 00000000000..b417ec6a482 --- /dev/null +++ b/prowler/providers/e2e/services/loadbalancer/loadbalancer_bitninja_enabled/loadbalancer_bitninja_enabled.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.loadbalancer.loadbalancer_client import ( + loadbalancer_client, +) + + +class loadbalancer_bitninja_enabled(Check): + """Ensure load balancers have BitNinja protection enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for lb in loadbalancer_client.load_balancers: + report = CheckReportE2e(metadata=self.metadata(), resource=lb) + report.status = "PASS" + report.status_extended = ( + f"Load balancer {lb.name} has BitNinja protection enabled." + ) + if not lb.enable_bitninja: + report.status = "FAIL" + report.status_extended = f"Load balancer {lb.name} does not have BitNinja protection enabled." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_client.py b/prowler/providers/e2e/services/loadbalancer/loadbalancer_client.py new file mode 100644 index 00000000000..0eb89ce6377 --- /dev/null +++ b/prowler/providers/e2e/services/loadbalancer/loadbalancer_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.e2e.services.loadbalancer.loadbalancer_service import ( + LoadBalancers, +) + +loadbalancer_client = LoadBalancers(Provider.get_global_provider()) diff --git a/prowler/providers/e2e/services/loadbalancer/loadbalancer_service.py b/prowler/providers/e2e/services/loadbalancer/loadbalancer_service.py new file mode 100644 index 00000000000..18bae01b30a --- /dev/null +++ b/prowler/providers/e2e/services/loadbalancer/loadbalancer_service.py @@ -0,0 +1,96 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.e2e.lib.service.service import E2eService + + +class LoadBalancers(E2eService): + """Service class for E2E Cloud load balancers.""" + + def __init__(self, provider): + super().__init__("loadbalancer", provider) + self.load_balancers: list[LoadBalancer] = [] + self._fetch_loadbalancers() + + def _fetch_loadbalancers(self): + for location in self.provider.session.locations: + try: + appliances = self.client.paginate( + "/appliances/", + location=location, + ) + for item in appliances: + context = self._extract_context(item) + node_detail = item.get("node_detail", {}) or {} + self.load_balancers.append( + LoadBalancer( + id=str(item.get("id", "")), + name=item.get("name", ""), + location=location, + status=item.get("status", ""), + lb_mode=context.get("lb_mode", ""), + lb_port=str(context.get("lb_port", "")), + enable_bitninja=bool(context.get("enable_bitninja", False)), + ssl_certificate_id=self._get_ssl_certificate_id(context), + backends=context.get("backends", []) or [], + public_ip=node_detail.get("public_ip", ""), + ) + ) + except Exception as error: + logger.error( + f"loadbalancer - Error fetching appliances in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + @staticmethod + def _extract_context(item: dict) -> dict: + instances = item.get("appliance_instance", []) or [] + if not instances: + return {} + return instances[0].get("context", {}) or {} + + @staticmethod + def _get_ssl_certificate_id(context: dict) -> str | None: + ssl_context = context.get("ssl_context", {}) or {} + certificate_id = ssl_context.get("ssl_certificate_id") + if certificate_id in (None, "", 0): + return None + return str(certificate_id) + + +class LoadBalancer(BaseModel): + id: str + name: str + location: str + status: str = "" + lb_mode: str = "" + lb_port: str = "" + enable_bitninja: bool = False + ssl_certificate_id: str | None = None + backends: list = [] + public_ip: str = "" + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name + + @property + def is_alb(self) -> bool: + mode = self.lb_mode.upper() + return mode in ("HTTP", "HTTPS", "BOTH") + + @property + def is_alb_https(self) -> bool: + mode = self.lb_mode.upper() + return mode in ("HTTPS", "BOTH") + + @property + def has_backend_health_check(self) -> bool: + for backend in self.backends: + if isinstance(backend, dict) and backend.get("http_check"): + return True + return False diff --git a/prowler/providers/e2e/services/network/__init__.py b/prowler/providers/e2e/services/network/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/network/network_client.py b/prowler/providers/e2e/services/network/network_client.py new file mode 100644 index 00000000000..92cf447c31a --- /dev/null +++ b/prowler/providers/e2e/services/network/network_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.e2e.services.network.network_service import Network + +network_client = Network(Provider.get_global_provider()) diff --git a/prowler/providers/e2e/services/network/network_reserveip_floating_ip_unattached/__init__.py b/prowler/providers/e2e/services/network/network_reserveip_floating_ip_unattached/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/network/network_reserveip_floating_ip_unattached/network_reserveip_floating_ip_unattached.metadata.json b/prowler/providers/e2e/services/network/network_reserveip_floating_ip_unattached/network_reserveip_floating_ip_unattached.metadata.json new file mode 100644 index 00000000000..7fc1bbb439d --- /dev/null +++ b/prowler/providers/e2e/services/network/network_reserveip_floating_ip_unattached/network_reserveip_floating_ip_unattached.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "network_reserveip_floating_ip_unattached", + "CheckTitle": "Check if E2E Cloud floating IPs are attached to nodes", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud floating IPs are attached to nodes", + "Risk": "Unattached floating IPs may be reassigned unexpectedly and disrupt secure routing assumptions.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/reserve_ips//attach/?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"vm_id\":}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud floating IPs are attached to nodes", + "Url": "https://hub.prowler.com/check/network_reserveip_floating_ip_unattached" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/network/reserve-ip/" + ] +} diff --git a/prowler/providers/e2e/services/network/network_reserveip_floating_ip_unattached/network_reserveip_floating_ip_unattached.py b/prowler/providers/e2e/services/network/network_reserveip_floating_ip_unattached/network_reserveip_floating_ip_unattached.py new file mode 100644 index 00000000000..2ace7d09429 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_reserveip_floating_ip_unattached/network_reserveip_floating_ip_unattached.py @@ -0,0 +1,24 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.network.network_client import network_client + + +class network_reserveip_floating_ip_unattached(Check): + """Check if E2E Cloud floating IPs are attached to nodes.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for ip in network_client.reserved_ips: + if ip.reserved_type != "FloatingIP": + continue + report = CheckReportE2e(metadata=self.metadata(), resource=ip) + report.status = "PASS" + report.status_extended = ( + f"Floating IP {ip.ip_address} is attached to node(s)." + ) + if ip.status != "Attached" or ip.floating_ip_attached_nodes_count == 0: + report.status = "FAIL" + report.status_extended = ( + f"Floating IP {ip.ip_address} is not attached to any node." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/network/network_reserveip_orphaned_public_ip/__init__.py b/prowler/providers/e2e/services/network/network_reserveip_orphaned_public_ip/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/network/network_reserveip_orphaned_public_ip/network_reserveip_orphaned_public_ip.metadata.json b/prowler/providers/e2e/services/network/network_reserveip_orphaned_public_ip/network_reserveip_orphaned_public_ip.metadata.json new file mode 100644 index 00000000000..d97938a8be0 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_reserveip_orphaned_public_ip/network_reserveip_orphaned_public_ip.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "network_reserveip_orphaned_public_ip", + "CheckTitle": "Check if E2E Cloud public or addon IPs are attached", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud public or addon IPs are attached", + "Risk": "Orphaned public IPs remain internet-reachable resources that can be misused or reassigned without ownership controls.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X DELETE -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" \"https://api.e2enetworks.com/myaccount/api/v1/reserve_ips//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\"", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud public or addon IPs are attached", + "Url": "https://hub.prowler.com/check/network_reserveip_orphaned_public_ip" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/network/reserve-ip/" + ] +} diff --git a/prowler/providers/e2e/services/network/network_reserveip_orphaned_public_ip/network_reserveip_orphaned_public_ip.py b/prowler/providers/e2e/services/network/network_reserveip_orphaned_public_ip/network_reserveip_orphaned_public_ip.py new file mode 100644 index 00000000000..113e23983d3 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_reserveip_orphaned_public_ip/network_reserveip_orphaned_public_ip.py @@ -0,0 +1,24 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.network.network_client import network_client + + +class network_reserveip_orphaned_public_ip(Check): + """Check if E2E Cloud public or addon IPs are attached.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for ip in network_client.reserved_ips: + if ip.reserved_type not in ("PublicIP", "AddonIP"): + continue + report = CheckReportE2e(metadata=self.metadata(), resource=ip) + report.status = "PASS" + report.status_extended = ( + f"Reserved IP {ip.ip_address} is attached to a resource." + ) + if ip.status != "Attached" or ip.vm_id is None: + report.status = "FAIL" + report.status_extended = ( + f"Reserved IP {ip.ip_address} is orphaned (status: {ip.status})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/network/network_service.py b/prowler/providers/e2e/services/network/network_service.py new file mode 100644 index 00000000000..be2c1a91225 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_service.py @@ -0,0 +1,158 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.e2e.lib.service.service import E2eService + + +class Network(E2eService): + """Service class for E2E Cloud network resources.""" + + def __init__(self, provider): + super().__init__("network", provider) + self.vpcs: list[Vpc] = [] + self.reserved_ips: list[ReservedIp] = [] + self.vpc_tunnels: list[VpcTunnel] = [] + self._fetch_vpcs() + self._fetch_reserved_ips() + self._fetch_vpc_tunnels() + + def _fetch_vpcs(self): + for location in self.provider.session.locations: + try: + vpcs = self.client.paginate("/vpc/list/", location=location) + if not isinstance(vpcs, list): + continue + for item in vpcs: + gateway_node = item.get("gateway_node", {}) or {} + self.vpcs.append( + Vpc( + network_id=str(item.get("network_id", "")), + name=item.get("name", ""), + location=location, + is_active=bool(item.get("is_active", False)), + state=item.get("state", ""), + ipv4_cidr=item.get("ipv4_cidr", ""), + vm_count=int(item.get("vm_count", 0)), + gateway_node_id=str(gateway_node.get("node_id", "")), + gateway_public_ip=gateway_node.get("ip_address_public", ""), + ) + ) + except Exception as error: + logger.error( + f"network - Error fetching VPCs in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_reserved_ips(self): + for location in self.provider.session.locations: + try: + ips = self.client.get_data("/reserve_ips/", location=location) + if not isinstance(ips, list): + continue + + for item in ips: + attached_nodes = item.get("floating_ip_attached_nodes", []) or [] + self.reserved_ips.append( + ReservedIp( + reserve_id=str(item.get("reserve_id", "")), + ip_address=item.get("ip_address", ""), + location=location, + status=item.get("status", ""), + reserved_type=item.get("reserved_type", ""), + vm_id=item.get("vm_id"), + floating_ip_attached_nodes_count=len(attached_nodes), + ) + ) + except Exception as error: + logger.error( + f"network - Error fetching reserved IPs in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_vpc_tunnels(self): + for vpc in self.vpcs: + if not vpc.network_id: + continue + try: + tunnels = self.client.get_data( + f"/vpc/tunnels/{vpc.network_id}/", + location=vpc.location, + ) + if not isinstance(tunnels, list): + continue + + for item in tunnels: + self.vpc_tunnels.append( + VpcTunnel( + id=str(item.get("id", "")), + name=item.get("name", ""), + location=vpc.location, + local_vpc_network_id=vpc.network_id, + local_vpc_name=vpc.name, + status=item.get("status", ""), + is_peer_vpc_external=bool( + item.get("is_peer_vpc_external", False) + ), + ) + ) + except Exception as error: + logger.error( + f"network - Error fetching tunnels for VPC {vpc.network_id} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Vpc(BaseModel): + network_id: str + name: str + location: str + is_active: bool = False + state: str = "" + ipv4_cidr: str = "" + vm_count: int = 0 + gateway_node_id: str = "" + gateway_public_ip: str = "" + + @property + def resource_id(self) -> str: + return self.network_id + + @property + def resource_name(self) -> str: + return self.name + + +class ReservedIp(BaseModel): + reserve_id: str + ip_address: str + location: str + status: str = "" + reserved_type: str = "" + vm_id: int | None = None + floating_ip_attached_nodes_count: int = 0 + + @property + def resource_id(self) -> str: + return self.reserve_id + + @property + def resource_name(self) -> str: + return self.ip_address + + +class VpcTunnel(BaseModel): + id: str + name: str + location: str + local_vpc_network_id: str + local_vpc_name: str + status: str = "" + is_peer_vpc_external: bool = False + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name diff --git a/prowler/providers/e2e/services/network/network_vpc_has_attached_nodes/__init__.py b/prowler/providers/e2e/services/network/network_vpc_has_attached_nodes/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/network/network_vpc_has_attached_nodes/network_vpc_has_attached_nodes.metadata.json b/prowler/providers/e2e/services/network/network_vpc_has_attached_nodes/network_vpc_has_attached_nodes.metadata.json new file mode 100644 index 00000000000..2ed96c9dc79 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_vpc_has_attached_nodes/network_vpc_has_attached_nodes.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "network_vpc_has_attached_nodes", + "CheckTitle": "Check if E2E Cloud VPCs have attached nodes", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud VPCs have attached nodes", + "Risk": "VPCs without attached nodes may indicate unused network segments or misconfiguration that weakens segmentation.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/nodes//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"vpc_id\":}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud VPCs have attached nodes", + "Url": "https://hub.prowler.com/check/network_vpc_has_attached_nodes" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/network/vpc/" + ] +} diff --git a/prowler/providers/e2e/services/network/network_vpc_has_attached_nodes/network_vpc_has_attached_nodes.py b/prowler/providers/e2e/services/network/network_vpc_has_attached_nodes/network_vpc_has_attached_nodes.py new file mode 100644 index 00000000000..c5ed7f4f1eb --- /dev/null +++ b/prowler/providers/e2e/services/network/network_vpc_has_attached_nodes/network_vpc_has_attached_nodes.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.network.network_client import network_client + + +class network_vpc_has_attached_nodes(Check): + """Check if E2E Cloud VPCs have attached nodes.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for vpc in network_client.vpcs: + report = CheckReportE2e(metadata=self.metadata(), resource=vpc) + report.status = "PASS" + report.status_extended = ( + f"VPC {vpc.name} has {vpc.vm_count} attached node(s)." + ) + if vpc.vm_count <= 0: + report.status = "FAIL" + report.status_extended = f"VPC {vpc.name} has no attached nodes." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/network/network_vpc_is_active/__init__.py b/prowler/providers/e2e/services/network/network_vpc_is_active/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/network/network_vpc_is_active/network_vpc_is_active.metadata.json b/prowler/providers/e2e/services/network/network_vpc_is_active/network_vpc_is_active.metadata.json new file mode 100644 index 00000000000..b1dde659fa1 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_vpc_is_active/network_vpc_is_active.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "network_vpc_is_active", + "CheckTitle": "Check if E2E Cloud VPCs are active", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud VPCs are active", + "Risk": "Inactive VPCs can cause connectivity failures and may leave workloads without expected network isolation.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/vpc//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_active\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud VPCs are active", + "Url": "https://hub.prowler.com/check/network_vpc_is_active" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/network/vpc/" + ] +} diff --git a/prowler/providers/e2e/services/network/network_vpc_is_active/network_vpc_is_active.py b/prowler/providers/e2e/services/network/network_vpc_is_active/network_vpc_is_active.py new file mode 100644 index 00000000000..3104a8fd2d3 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_vpc_is_active/network_vpc_is_active.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.network.network_client import network_client + + +class network_vpc_is_active(Check): + """Check if E2E Cloud VPCs are active.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for vpc in network_client.vpcs: + report = CheckReportE2e(metadata=self.metadata(), resource=vpc) + report.status = "PASS" + report.status_extended = f"VPC {vpc.name} is active." + if not vpc.is_active or vpc.state != "Active": + report.status = "FAIL" + report.status_extended = ( + f"VPC {vpc.name} is not active (state: {vpc.state})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/network/network_vpc_peering_external_peer_disabled/__init__.py b/prowler/providers/e2e/services/network/network_vpc_peering_external_peer_disabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/network/network_vpc_peering_external_peer_disabled/network_vpc_peering_external_peer_disabled.metadata.json b/prowler/providers/e2e/services/network/network_vpc_peering_external_peer_disabled/network_vpc_peering_external_peer_disabled.metadata.json new file mode 100644 index 00000000000..98a236545e8 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_vpc_peering_external_peer_disabled/network_vpc_peering_external_peer_disabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "network_vpc_peering_external_peer_disabled", + "CheckTitle": "Check if E2E Cloud VPC peering does not use external peers", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud VPC peering does not use external peers", + "Risk": "VPC peering with external peers can expand trust boundaries and expose private networks to third parties.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X DELETE -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" \"https://api.e2enetworks.com/myaccount/api/v1/vpc/tunnels//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\"", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud VPC peering does not use external peers", + "Url": "https://hub.prowler.com/check/network_vpc_peering_external_peer_disabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/network/vpc/" + ] +} diff --git a/prowler/providers/e2e/services/network/network_vpc_peering_external_peer_disabled/network_vpc_peering_external_peer_disabled.py b/prowler/providers/e2e/services/network/network_vpc_peering_external_peer_disabled/network_vpc_peering_external_peer_disabled.py new file mode 100644 index 00000000000..b13d9232952 --- /dev/null +++ b/prowler/providers/e2e/services/network/network_vpc_peering_external_peer_disabled/network_vpc_peering_external_peer_disabled.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.network.network_client import network_client + + +class network_vpc_peering_external_peer_disabled(Check): + """Check if E2E Cloud VPC peering does not use external peers.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for tunnel in network_client.vpc_tunnels: + report = CheckReportE2e(metadata=self.metadata(), resource=tunnel) + report.status = "PASS" + report.status_extended = ( + f"VPC peering {tunnel.name} does not use an external peer VPC." + ) + if tunnel.is_peer_vpc_external: + report.status = "FAIL" + report.status_extended = ( + f"VPC peering {tunnel.name} uses an external peer VPC." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/node/__init__.py b/prowler/providers/e2e/services/node/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/node/node_accidental_protection_enabled/__init__.py b/prowler/providers/e2e/services/node/node_accidental_protection_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/node/node_accidental_protection_enabled/node_accidental_protection_enabled.metadata.json b/prowler/providers/e2e/services/node/node_accidental_protection_enabled/node_accidental_protection_enabled.metadata.json new file mode 100644 index 00000000000..0534fbbe52b --- /dev/null +++ b/prowler/providers/e2e/services/node/node_accidental_protection_enabled/node_accidental_protection_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "node_accidental_protection_enabled", + "CheckTitle": "Check if E2E Cloud nodes have accidental protection enabled", + "CheckType": [], + "ServiceName": "node", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "compute", + "Description": "Check if E2E Cloud nodes have accidental protection enabled", + "Risk": "Nodes without accidental protection are easier to delete or modify unintentionally, increasing the risk of service disruption and unplanned exposure of compute resources.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/nodes//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_accidental_protection\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud nodes have accidental protection enabled", + "Url": "https://hub.prowler.com/check/node_accidental_protection_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/nodes/" + ] +} diff --git a/prowler/providers/e2e/services/node/node_accidental_protection_enabled/node_accidental_protection_enabled.py b/prowler/providers/e2e/services/node/node_accidental_protection_enabled/node_accidental_protection_enabled.py new file mode 100644 index 00000000000..8ddaeabc7c1 --- /dev/null +++ b/prowler/providers/e2e/services/node/node_accidental_protection_enabled/node_accidental_protection_enabled.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.node.nodes_client import nodes_client + + +class node_accidental_protection_enabled(Check): + """Check if E2E Cloud nodes have accidental protection enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for node in nodes_client.nodes: + report = CheckReportE2e(metadata=self.metadata(), resource=node) + report.status = "PASS" + report.status_extended = ( + f"Node {node.name} has accidental protection enabled." + ) + if not node.is_accidental_protection: + report.status = "FAIL" + report.status_extended = ( + f"Node {node.name} does not have accidental protection enabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/node/node_compliance_enabled/__init__.py b/prowler/providers/e2e/services/node/node_compliance_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/node/node_compliance_enabled/node_compliance_enabled.metadata.json b/prowler/providers/e2e/services/node/node_compliance_enabled/node_compliance_enabled.metadata.json new file mode 100644 index 00000000000..03faa11710e --- /dev/null +++ b/prowler/providers/e2e/services/node/node_compliance_enabled/node_compliance_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "node_compliance_enabled", + "CheckTitle": "Check if E2E Cloud nodes have compliance mode enabled", + "CheckType": [], + "ServiceName": "node", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "compute", + "Description": "Check if E2E Cloud nodes have compliance mode enabled", + "Risk": "Nodes without compliance mode enabled may not enforce required security controls, increasing misconfiguration risk and weakening auditability of compute workloads.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/nodes//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_node_compliance\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud nodes have compliance mode enabled", + "Url": "https://hub.prowler.com/check/node_compliance_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/nodes/" + ] +} diff --git a/prowler/providers/e2e/services/node/node_compliance_enabled/node_compliance_enabled.py b/prowler/providers/e2e/services/node/node_compliance_enabled/node_compliance_enabled.py new file mode 100644 index 00000000000..c726eb177e9 --- /dev/null +++ b/prowler/providers/e2e/services/node/node_compliance_enabled/node_compliance_enabled.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.node.nodes_client import nodes_client + + +class node_compliance_enabled(Check): + """Check if E2E Cloud nodes have compliance mode enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for node in nodes_client.nodes: + report = CheckReportE2e(metadata=self.metadata(), resource=node) + report.status = "PASS" + report.status_extended = f"Node {node.name} has compliance mode enabled." + if not node.is_node_compliance: + report.status = "FAIL" + report.status_extended = ( + f"Node {node.name} does not have compliance mode enabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/node/node_encryption_enabled/__init__.py b/prowler/providers/e2e/services/node/node_encryption_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/node/node_encryption_enabled/node_encryption_enabled.metadata.json b/prowler/providers/e2e/services/node/node_encryption_enabled/node_encryption_enabled.metadata.json new file mode 100644 index 00000000000..6ccfad5011a --- /dev/null +++ b/prowler/providers/e2e/services/node/node_encryption_enabled/node_encryption_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "node_encryption_enabled", + "CheckTitle": "Check if E2E Cloud nodes have encryption enabled", + "CheckType": [], + "ServiceName": "node", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "compute", + "Description": "Check if E2E Cloud nodes have encryption enabled", + "Risk": "Unencrypted nodes increase the risk of data exposure if disks or snapshots are accessed outside normal operating controls.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/nodes//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"isEncryptionEnabled\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud nodes have encryption enabled", + "Url": "https://hub.prowler.com/check/node_encryption_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/nodes/" + ] +} diff --git a/prowler/providers/e2e/services/node/node_encryption_enabled/node_encryption_enabled.py b/prowler/providers/e2e/services/node/node_encryption_enabled/node_encryption_enabled.py new file mode 100644 index 00000000000..cdb89e26b61 --- /dev/null +++ b/prowler/providers/e2e/services/node/node_encryption_enabled/node_encryption_enabled.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.node.nodes_client import nodes_client + + +class node_encryption_enabled(Check): + """Check if E2E Cloud nodes have encryption enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for node in nodes_client.nodes: + report = CheckReportE2e(metadata=self.metadata(), resource=node) + report.status = "PASS" + report.status_extended = f"Node {node.name} has encryption enabled." + if not node.is_encryption_enabled: + report.status = "FAIL" + report.status_extended = ( + f"Node {node.name} does not have encryption enabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/node/node_public_ip_not_assigned/__init__.py b/prowler/providers/e2e/services/node/node_public_ip_not_assigned/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/node/node_public_ip_not_assigned/node_public_ip_not_assigned.metadata.json b/prowler/providers/e2e/services/node/node_public_ip_not_assigned/node_public_ip_not_assigned.metadata.json new file mode 100644 index 00000000000..33bc7cad015 --- /dev/null +++ b/prowler/providers/e2e/services/node/node_public_ip_not_assigned/node_public_ip_not_assigned.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "node_public_ip_not_assigned", + "CheckTitle": "Check if E2E Cloud nodes do not have a public IP assigned", + "CheckType": [], + "ServiceName": "node", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "compute", + "Description": "Check if E2E Cloud nodes do not have a public IP assigned", + "Risk": "Nodes with public IP addresses are directly reachable from the internet, expanding the attack surface and increasing exposure to unauthorized access attempts.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/nodes//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"public_ip_address\":null}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud nodes do not have a public IP assigned", + "Url": "https://hub.prowler.com/check/node_public_ip_not_assigned" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/nodes/" + ] +} diff --git a/prowler/providers/e2e/services/node/node_public_ip_not_assigned/node_public_ip_not_assigned.py b/prowler/providers/e2e/services/node/node_public_ip_not_assigned/node_public_ip_not_assigned.py new file mode 100644 index 00000000000..74e83862810 --- /dev/null +++ b/prowler/providers/e2e/services/node/node_public_ip_not_assigned/node_public_ip_not_assigned.py @@ -0,0 +1,18 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.node.nodes_client import nodes_client + + +class node_public_ip_not_assigned(Check): + """Check if E2E Cloud nodes do not have a public IP assigned.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for node in nodes_client.nodes: + report = CheckReportE2e(metadata=self.metadata(), resource=node) + report.status = "PASS" + report.status_extended = f"Node {node.name} does not have a public IP." + if node.has_public_ip: + report.status = "FAIL" + report.status_extended = f"Node {node.name} has a public IP assigned." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/node/node_rescue_mode_disabled/__init__.py b/prowler/providers/e2e/services/node/node_rescue_mode_disabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/node/node_rescue_mode_disabled/node_rescue_mode_disabled.metadata.json b/prowler/providers/e2e/services/node/node_rescue_mode_disabled/node_rescue_mode_disabled.metadata.json new file mode 100644 index 00000000000..c60471fcd39 --- /dev/null +++ b/prowler/providers/e2e/services/node/node_rescue_mode_disabled/node_rescue_mode_disabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "node_rescue_mode_disabled", + "CheckTitle": "Check if E2E Cloud nodes do not have rescue mode enabled", + "CheckType": [], + "ServiceName": "node", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "compute", + "Description": "Check if E2E Cloud nodes do not have rescue mode enabled", + "Risk": "Rescue mode provides elevated access to node filesystems and can bypass normal operating controls, increasing the risk of unauthorized data access or persistence.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X POST -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/nodes//disable-rescue/?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\"", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud nodes do not have rescue mode enabled", + "Url": "https://hub.prowler.com/check/node_rescue_mode_disabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/nodes/" + ] +} diff --git a/prowler/providers/e2e/services/node/node_rescue_mode_disabled/node_rescue_mode_disabled.py b/prowler/providers/e2e/services/node/node_rescue_mode_disabled/node_rescue_mode_disabled.py new file mode 100644 index 00000000000..6f7d8fb46a5 --- /dev/null +++ b/prowler/providers/e2e/services/node/node_rescue_mode_disabled/node_rescue_mode_disabled.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.node.nodes_client import nodes_client + + +class node_rescue_mode_disabled(Check): + """Check if E2E Cloud nodes do not have rescue mode enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for node in nodes_client.nodes: + report = CheckReportE2e(metadata=self.metadata(), resource=node) + report.status = "PASS" + report.status_extended = ( + f"Node {node.name} does not have rescue mode enabled." + ) + if node.rescue_mode_status != "Disabled": + report.status = "FAIL" + report.status_extended = f"Node {node.name} has rescue mode enabled." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/node/node_vpc_attached/__init__.py b/prowler/providers/e2e/services/node/node_vpc_attached/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/node/node_vpc_attached/node_vpc_attached.metadata.json b/prowler/providers/e2e/services/node/node_vpc_attached/node_vpc_attached.metadata.json new file mode 100644 index 00000000000..01d2c3c020f --- /dev/null +++ b/prowler/providers/e2e/services/node/node_vpc_attached/node_vpc_attached.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "node_vpc_attached", + "CheckTitle": "Check if E2E Cloud nodes are attached to a VPC", + "CheckType": [], + "ServiceName": "node", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "compute", + "Description": "Check if E2E Cloud nodes are attached to a VPC", + "Risk": "Nodes not attached to a VPC may lack network isolation controls, making traffic routing and segmentation harder to enforce.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/nodes//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"vpc_id\":}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud nodes are attached to a VPC", + "Url": "https://hub.prowler.com/check/node_vpc_attached" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/compute/nodes/" + ] +} diff --git a/prowler/providers/e2e/services/node/node_vpc_attached/node_vpc_attached.py b/prowler/providers/e2e/services/node/node_vpc_attached/node_vpc_attached.py new file mode 100644 index 00000000000..990bea40b9f --- /dev/null +++ b/prowler/providers/e2e/services/node/node_vpc_attached/node_vpc_attached.py @@ -0,0 +1,18 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.node.nodes_client import nodes_client + + +class node_vpc_attached(Check): + """Check if E2E Cloud nodes are attached to a VPC.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for node in nodes_client.nodes: + report = CheckReportE2e(metadata=self.metadata(), resource=node) + report.status = "PASS" + report.status_extended = f"Node {node.name} is attached to a VPC." + if not node.is_vpc_attached: + report.status = "FAIL" + report.status_extended = f"Node {node.name} is not attached to a VPC." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/node/nodes_client.py b/prowler/providers/e2e/services/node/nodes_client.py new file mode 100644 index 00000000000..cecc935104d --- /dev/null +++ b/prowler/providers/e2e/services/node/nodes_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.e2e.services.node.nodes_service import Nodes + +nodes_client = Nodes(Provider.get_global_provider()) diff --git a/prowler/providers/e2e/services/node/nodes_service.py b/prowler/providers/e2e/services/node/nodes_service.py new file mode 100644 index 00000000000..0dc19522cd9 --- /dev/null +++ b/prowler/providers/e2e/services/node/nodes_service.py @@ -0,0 +1,109 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.e2e.lib.service.service import E2eService + + +def _has_public_ip(public_ip_address: str | None) -> bool: + if not public_ip_address: + return False + value = str(public_ip_address).strip() + if not value or value in ("[]", "null", "None"): + return False + return True + + +class Nodes(E2eService): + """Service class for E2E Cloud compute nodes.""" + + def __init__(self, provider): + super().__init__("nodes", provider) + self.nodes: list[Node] = [] + self._fetch_nodes() + + def _fetch_nodes(self): + for location in self.provider.session.locations: + try: + node_list = self.client.get_data("/nodes/", location=location) + if not isinstance(node_list, list): + continue + + for item in node_list: + node_id = str(item.get("id", "")) + detail = self._get_node_detail(node_id, location) + merged = {**item, **detail} + + self.nodes.append( + Node( + id=node_id, + name=merged.get("name", ""), + status=merged.get("status", ""), + location=location, + vm_id=str(merged.get("vm_id", merged.get("id", ""))), + public_ip_address=merged.get("public_ip_address"), + private_ip_address=merged.get("private_ip_address", ""), + is_accidental_protection=bool( + merged.get("is_accidental_protection", False) + ), + is_encryption_enabled=bool( + merged.get("isEncryptionEnabled", False) + ), + is_locked=bool(merged.get("is_locked", False)), + rescue_mode_status=merged.get( + "rescue_mode_status", "Disabled" + ), + is_node_compliance=bool( + merged.get("is_node_compliance", False) + ), + is_vpc_attached=bool(merged.get("is_vpc_attached", False)), + has_public_ip=_has_public_ip( + merged.get("public_ip_address") + ), + ) + ) + except Exception as error: + logger.error( + f"nodes - Error fetching nodes in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_node_detail(self, node_id: str, location: str) -> dict: + if not node_id: + return {} + try: + data = self.client.get_data( + f"/nodes/{node_id}/", + location=location, + ) + return data if isinstance(data, dict) else {} + except Exception as error: + logger.error( + f"nodes - Error fetching node detail {node_id} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + +class Node(BaseModel): + id: str + name: str + status: str + location: str + vm_id: str + public_ip_address: str | None = None + private_ip_address: str = "" + is_accidental_protection: bool = False + is_encryption_enabled: bool = False + is_locked: bool = False + rescue_mode_status: str = "Disabled" + is_node_compliance: bool = False + is_vpc_attached: bool = False + has_public_ip: bool = False + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name diff --git a/prowler/providers/e2e/services/securitygroup/__init__.py b/prowler/providers/e2e/services/securitygroup/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_client.py b/prowler/providers/e2e/services/securitygroup/securitygroup_client.py new file mode 100644 index 00000000000..a8a9b86044d --- /dev/null +++ b/prowler/providers/e2e/services/securitygroup/securitygroup_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.e2e.services.securitygroup.securitygroup_service import ( + SecurityGroups, +) + +securitygroup_client = SecurityGroups(Provider.get_global_provider()) diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_no_all_traffic_rule/__init__.py b/prowler/providers/e2e/services/securitygroup/securitygroup_no_all_traffic_rule/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_no_all_traffic_rule/securitygroup_no_all_traffic_rule.metadata.json b/prowler/providers/e2e/services/securitygroup/securitygroup_no_all_traffic_rule/securitygroup_no_all_traffic_rule.metadata.json new file mode 100644 index 00000000000..e403c614119 --- /dev/null +++ b/prowler/providers/e2e/services/securitygroup/securitygroup_no_all_traffic_rule/securitygroup_no_all_traffic_rule.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "securitygroup_no_all_traffic_rule", + "CheckTitle": "Check if E2E Cloud security groups do not allow all traffic", + "CheckType": [], + "ServiceName": "securitygroup", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "SecurityGroup", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud security groups do not have an all-traffic rule enabled.", + "Risk": "Security groups that allow all traffic permit unrestricted network access, increasing exposure to lateral movement and data exfiltration.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/security_group//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_all_traffic_rule\":false}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict security group rules to required ports and sources only.", + "Url": "https://hub.prowler.com/check/securitygroup_no_all_traffic_rule" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/network/security-group/" + ] +} diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_no_all_traffic_rule/securitygroup_no_all_traffic_rule.py b/prowler/providers/e2e/services/securitygroup/securitygroup_no_all_traffic_rule/securitygroup_no_all_traffic_rule.py new file mode 100644 index 00000000000..b591e480aae --- /dev/null +++ b/prowler/providers/e2e/services/securitygroup/securitygroup_no_all_traffic_rule/securitygroup_no_all_traffic_rule.py @@ -0,0 +1,24 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.securitygroup.securitygroup_client import ( + securitygroup_client, +) + + +class securitygroup_no_all_traffic_rule(Check): + """Ensure security groups do not allow all traffic.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for group in securitygroup_client.security_groups: + report = CheckReportE2e(metadata=self.metadata(), resource=group) + report.status = "PASS" + report.status_extended = ( + f"Security group {group.name} does not allow all traffic." + ) + if group.is_all_traffic_rule: + report.status = "FAIL" + report.status_extended = ( + f"Security group {group.name} allows all traffic." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_no_inbound_any_all_ports/__init__.py b/prowler/providers/e2e/services/securitygroup/securitygroup_no_inbound_any_all_ports/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_no_inbound_any_all_ports/securitygroup_no_inbound_any_all_ports.metadata.json b/prowler/providers/e2e/services/securitygroup/securitygroup_no_inbound_any_all_ports/securitygroup_no_inbound_any_all_ports.metadata.json new file mode 100644 index 00000000000..dae8e94a839 --- /dev/null +++ b/prowler/providers/e2e/services/securitygroup/securitygroup_no_inbound_any_all_ports/securitygroup_no_inbound_any_all_ports.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "securitygroup_no_inbound_any_all_ports", + "CheckTitle": "Check if E2E Cloud security groups do not allow inbound all-protocol traffic from any source", + "CheckType": [], + "ServiceName": "securitygroup", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "SecurityGroup", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud security groups do not allow inbound all-protocol traffic from any source.", + "Risk": "Inbound all-protocol rules from any source expose services to the entire internet and increase the likelihood of unauthorized access.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X DELETE -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" \"https://api.e2enetworks.com/myaccount/api/v1/security_group//rules//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\"", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Replace overly permissive inbound rules with least-privilege access.", + "Url": "https://hub.prowler.com/check/securitygroup_no_inbound_any_all_ports" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/network/security-group/" + ] +} diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_no_inbound_any_all_ports/securitygroup_no_inbound_any_all_ports.py b/prowler/providers/e2e/services/securitygroup/securitygroup_no_inbound_any_all_ports/securitygroup_no_inbound_any_all_ports.py new file mode 100644 index 00000000000..22694a0e376 --- /dev/null +++ b/prowler/providers/e2e/services/securitygroup/securitygroup_no_inbound_any_all_ports/securitygroup_no_inbound_any_all_ports.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.securitygroup.securitygroup_client import ( + securitygroup_client, +) + + +def _is_open_network(value: str | None) -> bool: + if value is None: + return False + normalized = str(value).lower().strip() + return normalized in ("any", "0.0.0.0/0", "::/0") + + +def _is_permissive_inbound(rule) -> bool: + if (rule.rule_type or "").lower() != "inbound": + return False + if (rule.protocol_name or "").lower() != "all": + return False + return _is_open_network(rule.network) or _is_open_network(rule.network_cidr) + + +class securitygroup_no_inbound_any_all_ports(Check): + """Check if E2E Cloud security groups do not allow inbound all-protocol traffic from any source.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for group in securitygroup_client.security_groups: + report = CheckReportE2e(metadata=self.metadata(), resource=group) + report.status = "PASS" + report.status_extended = f"Security group {group.name} does not allow inbound all-protocol traffic from any source." + if any(_is_permissive_inbound(rule) for rule in group.rules): + report.status = "FAIL" + report.status_extended = f"Security group {group.name} allows inbound all-protocol traffic from any source." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_restrictive_default/__init__.py b/prowler/providers/e2e/services/securitygroup/securitygroup_restrictive_default/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_restrictive_default/securitygroup_restrictive_default.metadata.json b/prowler/providers/e2e/services/securitygroup/securitygroup_restrictive_default/securitygroup_restrictive_default.metadata.json new file mode 100644 index 00000000000..3f59e0fea72 --- /dev/null +++ b/prowler/providers/e2e/services/securitygroup/securitygroup_restrictive_default/securitygroup_restrictive_default.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "e2e", + "CheckID": "securitygroup_restrictive_default", + "CheckTitle": "Check if E2E Cloud nodes do not rely on permissive default security groups", + "CheckType": [], + "ServiceName": "securitygroup", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "SecurityGroup", + "ResourceGroup": "network", + "Description": "Check if E2E Cloud nodes do not use only default security groups with overly permissive inbound rules.", + "Risk": "Nodes that rely only on permissive default security groups inherit broad inbound access, weakening network segmentation and trust boundaries.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X POST -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/security_group//attach/?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"vm_id\":}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach custom security groups with least-privilege rules to nodes.", + "Url": "https://hub.prowler.com/check/securitygroup_restrictive_default" + } + }, + "Categories": [ + "trust-boundaries", + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/network/security-group/" + ] +} diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_restrictive_default/securitygroup_restrictive_default.py b/prowler/providers/e2e/services/securitygroup/securitygroup_restrictive_default/securitygroup_restrictive_default.py new file mode 100644 index 00000000000..7a056519c56 --- /dev/null +++ b/prowler/providers/e2e/services/securitygroup/securitygroup_restrictive_default/securitygroup_restrictive_default.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.securitygroup.securitygroup_client import ( + securitygroup_client, +) + + +def _is_open_network(value: str | None) -> bool: + if value is None: + return False + normalized = str(value).lower().strip() + return normalized in ("any", "0.0.0.0/0", "::/0") + + +def _has_permissive_inbound(rules) -> bool: + for rule in rules: + if ( + (rule.rule_type or "").lower() == "inbound" + and (rule.protocol_name or "").lower() == "all" + and (_is_open_network(rule.network) or _is_open_network(rule.network_cidr)) + ): + return True + return False + + +class securitygroup_restrictive_default(Check): + """Check if E2E Cloud nodes do not rely on permissive default security groups.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + node_groups: dict[str, list] = {} + for group in securitygroup_client.node_security_groups: + node_groups.setdefault(group.node_id, []).append(group) + + for node_id, groups in node_groups.items(): + resource = groups[0] + report = CheckReportE2e(metadata=self.metadata(), resource=resource) + report.status = "PASS" + report.status_extended = f"Node {resource.node_name} does not rely on a permissive default security group." + + default_groups = [group for group in groups if group.is_default] + if default_groups and len(groups) == len(default_groups): + if any( + _has_permissive_inbound(group.rules) for group in default_groups + ): + report.status = "FAIL" + report.status_extended = f"Node {resource.node_name} uses only default security groups with overly permissive inbound rules." + + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/securitygroup/securitygroup_service.py b/prowler/providers/e2e/services/securitygroup/securitygroup_service.py new file mode 100644 index 00000000000..0d94e7418de --- /dev/null +++ b/prowler/providers/e2e/services/securitygroup/securitygroup_service.py @@ -0,0 +1,147 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.e2e.lib.service.service import E2eService + + +class SecurityGroups(E2eService): + """Service class for E2E Cloud security groups.""" + + def __init__(self, provider): + super().__init__("securitygroup", provider) + self.security_groups: list[SecurityGroupResource] = [] + self.node_security_groups: list[NodeSecurityGroup] = [] + self._fetch_security_groups() + self._fetch_node_security_groups() + + def _fetch_security_groups(self): + for location in self.provider.session.locations: + try: + groups = self.client.get_data("/security_group/", location=location) + if not isinstance(groups, list): + continue + + for item in groups: + rules = [ + SecurityGroupRule( + id=str(rule.get("id", "")), + rule_type=rule.get("rule_type", ""), + protocol_name=rule.get("protocol_name", ""), + port_range=rule.get("port_range", ""), + network=rule.get("network", ""), + network_cidr=rule.get("network_cidr", ""), + ) + for rule in item.get("rules", []) + ] + self.security_groups.append( + SecurityGroupResource( + id=str(item.get("id", "")), + name=item.get("name", ""), + location=location, + description=item.get("description", ""), + is_default=bool(item.get("is_default", False)), + is_all_traffic_rule=bool( + item.get("is_all_traffic_rule", False) + ), + rules=rules, + ) + ) + except Exception as error: + logger.error( + f"securitygroup - Error fetching groups in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_node_security_groups(self): + from prowler.providers.e2e.services.node.nodes_client import nodes_client + + for node in nodes_client.nodes: + if not node.vm_id: + continue + try: + attached = self.client.get_data( + f"/security_group/{node.vm_id}/attach/", + location=node.location, + ) + if not isinstance(attached, list): + continue + + for item in attached: + rules = [ + SecurityGroupRule( + id=str(rule.get("id", "")), + rule_type=rule.get("rule_type", ""), + protocol_name=rule.get("protocol_name", ""), + port_range=rule.get("port_range", ""), + network=rule.get("network", ""), + network_cidr=rule.get("network_cidr", ""), + ) + for rule in item.get("rules", []) + ] + self.node_security_groups.append( + NodeSecurityGroup( + node_id=node.id, + node_name=node.name, + vm_id=node.vm_id, + location=node.location, + security_group_id=str(item.get("id", "")), + name=item.get("name", ""), + is_default=bool(item.get("is_default", False)), + is_all_traffic_rule=bool( + item.get("is_all_traffic_rule", False) + ), + rules=rules, + ) + ) + except Exception as error: + logger.error( + f"securitygroup - Error fetching attached groups for node {node.id} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class SecurityGroupRule(BaseModel): + id: str + rule_type: str + protocol_name: str + port_range: str + network: str + network_cidr: str + + +class SecurityGroupResource(BaseModel): + id: str + name: str + location: str + description: str = "" + is_default: bool = False + is_all_traffic_rule: bool = False + rules: list[SecurityGroupRule] = [] + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name + + +class NodeSecurityGroup(BaseModel): + node_id: str + node_name: str + vm_id: str + location: str + security_group_id: str + name: str + is_default: bool = False + is_all_traffic_rule: bool = False + rules: list[SecurityGroupRule] = [] + + @property + def resource_id(self) -> str: + return f"{self.node_id}:{self.security_group_id}" + + @property + def resource_name(self) -> str: + return f"{self.node_name}/{self.name}" diff --git a/prowler/providers/e2e/services/storage/__init__.py b/prowler/providers/e2e/services/storage/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_block_volume_not_orphaned/__init__.py b/prowler/providers/e2e/services/storage/storage_block_volume_not_orphaned/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_block_volume_not_orphaned/storage_block_volume_not_orphaned.metadata.json b/prowler/providers/e2e/services/storage/storage_block_volume_not_orphaned/storage_block_volume_not_orphaned.metadata.json new file mode 100644 index 00000000000..377554a999e --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_block_volume_not_orphaned/storage_block_volume_not_orphaned.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "storage_block_volume_not_orphaned", + "CheckTitle": "Check if E2E Cloud block volumes are not orphaned", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "BlockVolume", + "ResourceGroup": "storage", + "Description": "Check if E2E Cloud block volumes in Available state are attached to a node.", + "Risk": "Orphaned block volumes remain provisioned without an attached workload, increasing storage cost and the risk of retaining sensitive data without active ownership.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X POST -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/block_storage//attach/?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"vm_id\":}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach available block volumes to nodes or delete unused volumes.", + "Url": "https://hub.prowler.com/check/storage_block_volume_not_orphaned" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/storage/block-storage/" + ] +} diff --git a/prowler/providers/e2e/services/storage/storage_block_volume_not_orphaned/storage_block_volume_not_orphaned.py b/prowler/providers/e2e/services/storage/storage_block_volume_not_orphaned/storage_block_volume_not_orphaned.py new file mode 100644 index 00000000000..34e0865e0a1 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_block_volume_not_orphaned/storage_block_volume_not_orphaned.py @@ -0,0 +1,18 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.storage.storage_client import storage_client + + +class storage_block_volume_not_orphaned(Check): + """Ensure available block volumes are attached to a node.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for volume in storage_client.block_volumes: + report = CheckReportE2e(metadata=self.metadata(), resource=volume) + report.status = "PASS" + report.status_extended = f"Block volume {volume.name} is attached or not in an available orphaned state." + if volume.status == "Available" and not volume.is_attached: + report.status = "FAIL" + report.status_extended = f"Block volume {volume.name} is available and not attached to any node." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/storage/storage_bucket_encryption_enabled/__init__.py b/prowler/providers/e2e/services/storage/storage_bucket_encryption_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_bucket_encryption_enabled/storage_bucket_encryption_enabled.metadata.json b/prowler/providers/e2e/services/storage/storage_bucket_encryption_enabled/storage_bucket_encryption_enabled.metadata.json new file mode 100644 index 00000000000..44ab4fa48a8 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_encryption_enabled/storage_bucket_encryption_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "storage_bucket_encryption_enabled", + "CheckTitle": "Check if E2E Cloud object storage buckets have encryption enabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "ObjectStorageBucket", + "ResourceGroup": "storage", + "Description": "Check if E2E Cloud object storage buckets have encryption enabled.", + "Risk": "Unencrypted object storage buckets increase risk of data exposure if storage access controls fail.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/storage/buckets//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_encryption_enabled\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable encryption on object storage buckets.", + "Url": "https://hub.prowler.com/check/storage_bucket_encryption_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/storage/object-storage/" + ] +} diff --git a/prowler/providers/e2e/services/storage/storage_bucket_encryption_enabled/storage_bucket_encryption_enabled.py b/prowler/providers/e2e/services/storage/storage_bucket_encryption_enabled/storage_bucket_encryption_enabled.py new file mode 100644 index 00000000000..f1597e95342 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_encryption_enabled/storage_bucket_encryption_enabled.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.storage.storage_client import storage_client + + +class storage_bucket_encryption_enabled(Check): + """Ensure object storage buckets have encryption enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for bucket in storage_client.buckets: + report = CheckReportE2e(metadata=self.metadata(), resource=bucket) + report.status = "PASS" + report.status_extended = ( + f"Object storage bucket {bucket.name} has encryption enabled." + ) + if not bucket.is_encryption_enabled: + report.status = "FAIL" + report.status_extended = f"Object storage bucket {bucket.name} does not have encryption enabled." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/storage/storage_bucket_lifecycle_configured/__init__.py b/prowler/providers/e2e/services/storage/storage_bucket_lifecycle_configured/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_bucket_lifecycle_configured/storage_bucket_lifecycle_configured.metadata.json b/prowler/providers/e2e/services/storage/storage_bucket_lifecycle_configured/storage_bucket_lifecycle_configured.metadata.json new file mode 100644 index 00000000000..9dc0e29b2e0 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_lifecycle_configured/storage_bucket_lifecycle_configured.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "storage_bucket_lifecycle_configured", + "CheckTitle": "Check if E2E Cloud object storage buckets have lifecycle configuration enabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "storage", + "Description": "Check if E2E Cloud object storage buckets have lifecycle configuration enabled", + "Risk": "Buckets without lifecycle rules may retain sensitive data longer than required and increase breach impact.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/storage/buckets//lifecycle/?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"status\":\"Configured\"}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud object storage buckets have lifecycle configuration enabled", + "Url": "https://hub.prowler.com/check/storage_bucket_lifecycle_configured" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/storage/object-storage/" + ] +} diff --git a/prowler/providers/e2e/services/storage/storage_bucket_lifecycle_configured/storage_bucket_lifecycle_configured.py b/prowler/providers/e2e/services/storage/storage_bucket_lifecycle_configured/storage_bucket_lifecycle_configured.py new file mode 100644 index 00000000000..f5e9476b7cb --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_lifecycle_configured/storage_bucket_lifecycle_configured.py @@ -0,0 +1,18 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.storage.storage_client import storage_client + + +class storage_bucket_lifecycle_configured(Check): + """Check if E2E Cloud object storage buckets have lifecycle configuration enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for bucket in storage_client.buckets: + report = CheckReportE2e(metadata=self.metadata(), resource=bucket) + report.status = "PASS" + report.status_extended = f"Object storage bucket {bucket.name} has lifecycle configuration enabled." + if bucket.lifecycle_configuration_status != "Configured": + report.status = "FAIL" + report.status_extended = f"Object storage bucket {bucket.name} does not have lifecycle configuration enabled." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/storage/storage_bucket_lock_enabled/__init__.py b/prowler/providers/e2e/services/storage/storage_bucket_lock_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_bucket_lock_enabled/storage_bucket_lock_enabled.metadata.json b/prowler/providers/e2e/services/storage/storage_bucket_lock_enabled/storage_bucket_lock_enabled.metadata.json new file mode 100644 index 00000000000..488208538ca --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_lock_enabled/storage_bucket_lock_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "storage_bucket_lock_enabled", + "CheckTitle": "Check if E2E Cloud object storage buckets have object lock enabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "storage", + "Description": "Check if E2E Cloud object storage buckets have object lock enabled", + "Risk": "Buckets without object lock are more vulnerable to destructive overwrites and ransomware-style deletion.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/storage/buckets//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_lock_enabled\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud object storage buckets have object lock enabled", + "Url": "https://hub.prowler.com/check/storage_bucket_lock_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/storage/object-storage/" + ] +} diff --git a/prowler/providers/e2e/services/storage/storage_bucket_lock_enabled/storage_bucket_lock_enabled.py b/prowler/providers/e2e/services/storage/storage_bucket_lock_enabled/storage_bucket_lock_enabled.py new file mode 100644 index 00000000000..9a0a44f24f5 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_lock_enabled/storage_bucket_lock_enabled.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.storage.storage_client import storage_client + + +class storage_bucket_lock_enabled(Check): + """Check if E2E Cloud object storage buckets have object lock enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for bucket in storage_client.buckets: + report = CheckReportE2e(metadata=self.metadata(), resource=bucket) + report.status = "PASS" + report.status_extended = ( + f"Object storage bucket {bucket.name} has object lock enabled." + ) + if not bucket.is_lock_enabled: + report.status = "FAIL" + report.status_extended = f"Object storage bucket {bucket.name} does not have object lock enabled." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/storage/storage_bucket_public_access_disabled/__init__.py b/prowler/providers/e2e/services/storage/storage_bucket_public_access_disabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_bucket_public_access_disabled/storage_bucket_public_access_disabled.metadata.json b/prowler/providers/e2e/services/storage/storage_bucket_public_access_disabled/storage_bucket_public_access_disabled.metadata.json new file mode 100644 index 00000000000..cad84b17b74 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_public_access_disabled/storage_bucket_public_access_disabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "storage_bucket_public_access_disabled", + "CheckTitle": "Check if E2E Cloud object storage buckets do not allow public access", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "ObjectStorageBucket", + "ResourceGroup": "storage", + "Description": "Check if E2E Cloud object storage buckets have public access disabled.", + "Risk": "Public object storage buckets can expose sensitive data to anonymous internet access, weakening confidentiality and increasing data breach risk.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/storage/buckets//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_public_access_enabled\":false}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable public access on object storage buckets.", + "Url": "https://hub.prowler.com/check/storage_bucket_public_access_disabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/storage/object-storage/" + ] +} diff --git a/prowler/providers/e2e/services/storage/storage_bucket_public_access_disabled/storage_bucket_public_access_disabled.py b/prowler/providers/e2e/services/storage/storage_bucket_public_access_disabled/storage_bucket_public_access_disabled.py new file mode 100644 index 00000000000..1deba542b18 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_public_access_disabled/storage_bucket_public_access_disabled.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.storage.storage_client import storage_client + + +class storage_bucket_public_access_disabled(Check): + """Ensure object storage buckets do not allow public access.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for bucket in storage_client.buckets: + report = CheckReportE2e(metadata=self.metadata(), resource=bucket) + report.status = "PASS" + report.status_extended = ( + f"Object storage bucket {bucket.name} does not allow public access." + ) + if bucket.is_public_access_enabled: + report.status = "FAIL" + report.status_extended = ( + f"Object storage bucket {bucket.name} allows public access." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/storage/storage_bucket_versioning_enabled/__init__.py b/prowler/providers/e2e/services/storage/storage_bucket_versioning_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_bucket_versioning_enabled/storage_bucket_versioning_enabled.metadata.json b/prowler/providers/e2e/services/storage/storage_bucket_versioning_enabled/storage_bucket_versioning_enabled.metadata.json new file mode 100644 index 00000000000..6717e22a154 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_versioning_enabled/storage_bucket_versioning_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "storage_bucket_versioning_enabled", + "CheckTitle": "Check if E2E Cloud object storage buckets have versioning enabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "ObjectStorageBucket", + "ResourceGroup": "storage", + "Description": "Check if E2E Cloud object storage buckets have versioning enabled.", + "Risk": "Buckets without versioning cannot recover from accidental deletion or overwrite events, reducing data resilience and auditability.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/storage/buckets//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"versioning_status\":\"Enabled\"}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable versioning on object storage buckets.", + "Url": "https://hub.prowler.com/check/storage_bucket_versioning_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/storage/object-storage/" + ] +} diff --git a/prowler/providers/e2e/services/storage/storage_bucket_versioning_enabled/storage_bucket_versioning_enabled.py b/prowler/providers/e2e/services/storage/storage_bucket_versioning_enabled/storage_bucket_versioning_enabled.py new file mode 100644 index 00000000000..5193b50e661 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_bucket_versioning_enabled/storage_bucket_versioning_enabled.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.storage.storage_client import storage_client + + +class storage_bucket_versioning_enabled(Check): + """Ensure object storage buckets have versioning enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for bucket in storage_client.buckets: + report = CheckReportE2e(metadata=self.metadata(), resource=bucket) + report.status = "PASS" + report.status_extended = ( + f"Object storage bucket {bucket.name} has versioning enabled." + ) + if bucket.versioning_status != "Enabled": + report.status = "FAIL" + report.status_extended = f"Object storage bucket {bucket.name} does not have versioning enabled." + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/storage/storage_client.py b/prowler/providers/e2e/services/storage/storage_client.py new file mode 100644 index 00000000000..96adca9211d --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.e2e.services.storage.storage_service import Storage + +storage_client = Storage(Provider.get_global_provider()) diff --git a/prowler/providers/e2e/services/storage/storage_efs_backup_enabled/__init__.py b/prowler/providers/e2e/services/storage/storage_efs_backup_enabled/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_efs_backup_enabled/storage_efs_backup_enabled.metadata.json b/prowler/providers/e2e/services/storage/storage_efs_backup_enabled/storage_efs_backup_enabled.metadata.json new file mode 100644 index 00000000000..80d71acf937 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_efs_backup_enabled/storage_efs_backup_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "storage_efs_backup_enabled", + "CheckTitle": "Check if E2E Cloud EFS volumes have backup enabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "storage", + "Description": "Check if E2E Cloud EFS volumes have backup enabled", + "Risk": "Shared file systems without backups increase data-loss risk during failures or accidental changes.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/efs//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_backup_enabled\":true}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud EFS volumes have backup enabled", + "Url": "https://hub.prowler.com/check/storage_efs_backup_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/storage/parallel-file-storage/" + ] +} diff --git a/prowler/providers/e2e/services/storage/storage_efs_backup_enabled/storage_efs_backup_enabled.py b/prowler/providers/e2e/services/storage/storage_efs_backup_enabled/storage_efs_backup_enabled.py new file mode 100644 index 00000000000..34fece3b0b3 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_efs_backup_enabled/storage_efs_backup_enabled.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.storage.storage_client import storage_client + + +class storage_efs_backup_enabled(Check): + """Check if E2E Cloud EFS volumes have backup enabled.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for volume in storage_client.efs_volumes: + report = CheckReportE2e(metadata=self.metadata(), resource=volume) + report.status = "PASS" + report.status_extended = f"EFS volume {volume.name} has backup enabled." + if not volume.is_backup_enabled: + report.status = "FAIL" + report.status_extended = ( + f"EFS volume {volume.name} does not have backup enabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/storage/storage_efs_vpc_access_restricted/__init__.py b/prowler/providers/e2e/services/storage/storage_efs_vpc_access_restricted/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/e2e/services/storage/storage_efs_vpc_access_restricted/storage_efs_vpc_access_restricted.metadata.json b/prowler/providers/e2e/services/storage/storage_efs_vpc_access_restricted/storage_efs_vpc_access_restricted.metadata.json new file mode 100644 index 00000000000..f179380e6a9 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_efs_vpc_access_restricted/storage_efs_vpc_access_restricted.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "e2e", + "CheckID": "storage_efs_vpc_access_restricted", + "CheckTitle": "Check if E2E Cloud EFS volumes restrict VPC access", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "storage", + "Description": "Check if E2E Cloud EFS volumes restrict VPC access", + "Risk": "EFS volumes allowing all VPC resources broaden access beyond least privilege within the network.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "curl -X PUT -H \"Authorization: Bearer $E2E_AUTH_TOKEN\" -H \"Content-Type: application/json\" \"https://api.e2enetworks.com/myaccount/api/v1/efs//?apikey=$E2E_API_KEY&project_id=$E2E_PROJECT_ID&location=\" -d '{\"is_all_vpc_resources_allowed\":false}'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Check if E2E Cloud EFS volumes restrict VPC access", + "Url": "https://hub.prowler.com/check/storage_efs_vpc_access_restricted" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.e2enetworks.com/api/myaccount/storage/parallel-file-storage/" + ] +} diff --git a/prowler/providers/e2e/services/storage/storage_efs_vpc_access_restricted/storage_efs_vpc_access_restricted.py b/prowler/providers/e2e/services/storage/storage_efs_vpc_access_restricted/storage_efs_vpc_access_restricted.py new file mode 100644 index 00000000000..f8deeb4f0f7 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_efs_vpc_access_restricted/storage_efs_vpc_access_restricted.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, CheckReportE2e +from prowler.providers.e2e.services.storage.storage_client import storage_client + + +class storage_efs_vpc_access_restricted(Check): + """Check if E2E Cloud EFS volumes restrict VPC access.""" + + def execute(self) -> list[CheckReportE2e]: + findings = [] + for volume in storage_client.efs_volumes: + report = CheckReportE2e(metadata=self.metadata(), resource=volume) + report.status = "PASS" + report.status_extended = ( + f"EFS volume {volume.name} does not allow all VPC resources." + ) + if volume.is_all_vpc_resources_allowed: + report.status = "FAIL" + report.status_extended = ( + f"EFS volume {volume.name} allows access from all VPC resources." + ) + findings.append(report) + return findings diff --git a/prowler/providers/e2e/services/storage/storage_service.py b/prowler/providers/e2e/services/storage/storage_service.py new file mode 100644 index 00000000000..cd4ef992811 --- /dev/null +++ b/prowler/providers/e2e/services/storage/storage_service.py @@ -0,0 +1,213 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.e2e.lib.service.service import E2eService + + +class Storage(E2eService): + """Service class for E2E Cloud storage resources.""" + + def __init__(self, provider): + super().__init__("storage", provider) + self.block_volumes: list[BlockVolume] = [] + self.buckets: list[StorageBucket] = [] + self.efs_volumes: list[EfsVolume] = [] + self.epfs_volumes: list[EpfsVolume] = [] + self._fetch_block_volumes() + self._fetch_buckets() + self._fetch_efs_volumes() + self._fetch_epfs_volumes() + + def _fetch_block_volumes(self): + for location in self.provider.session.locations: + try: + volumes = self.client.paginate( + "/block_storage/", + location=location, + ) + for item in volumes: + vm_detail = item.get("vm_detail", {}) or {} + self.block_volumes.append( + BlockVolume( + id=str(item.get("block_id", "")), + name=item.get("name", ""), + location=location, + status=item.get("status", ""), + size_string=item.get("size_string", ""), + is_attached=bool(vm_detail), + ) + ) + except Exception as error: + logger.error( + f"storage - Error fetching block volumes in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_buckets(self): + for location in self.provider.session.locations: + try: + buckets = self.client.paginate( + "/storage/buckets/", + location=location, + ) + for item in buckets: + self.buckets.append( + StorageBucket( + id=str(item.get("id", "")), + name=item.get("name", ""), + location=location, + status=item.get("status", ""), + versioning_status=item.get("versioning_status", "Off"), + is_public_access_enabled=bool( + item.get("is_public_access_enabled", False) + ), + is_encryption_enabled=bool( + item.get("is_encryption_enabled", False) + ), + is_lock_enabled=bool(item.get("is_lock_enabled", False)), + lifecycle_configuration_status=item.get( + "lifecycle_configuration_status", "" + ), + ) + ) + except Exception as error: + logger.error( + f"storage - Error fetching buckets in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_efs_volumes(self): + for location in self.provider.session.locations: + try: + volumes = self.client.paginate("/efs/", location=location) + for item in volumes: + self.efs_volumes.append( + EfsVolume( + id=str(item.get("id", "")), + name=item.get("name", ""), + location=location, + status=item.get("status", ""), + vpc_id=str(item.get("vpc_id", "")), + is_backup_enabled=bool( + item.get("is_backup_enabled", False) + ), + is_all_vpc_resources_allowed=bool( + item.get("is_all_vpc_resources_allowed", False) + ), + ) + ) + except Exception as error: + logger.error( + f"storage - Error fetching EFS volumes in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_epfs_volumes(self): + for location in self.provider.session.locations: + try: + all_items: list = [] + page = 1 + total_pages = 1 + while page <= total_pages: + payload = self.client.get( + "/epfs/", + location=location, + params={"page": page, "page_size": 100}, + ) + data = payload.get("data", []) + if isinstance(data, list): + all_items.extend(data) + total_pages = int(payload.get("total_page_number", page)) + if not data: + break + page += 1 + + for item in all_items: + vpc = item.get("vpc", {}) or {} + self.epfs_volumes.append( + EpfsVolume( + id=str(item.get("id", "")), + name=item.get("name", ""), + location=location, + vpc_network_id=str(vpc.get("network_id", "")), + vpc_name=vpc.get("name", ""), + deleted=bool(item.get("deleted", False)), + ) + ) + except Exception as error: + logger.error( + f"storage - Error fetching EPFS volumes in {location} -- " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class BlockVolume(BaseModel): + id: str + name: str + location: str + status: str = "" + size_string: str = "" + is_attached: bool = False + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name + + +class StorageBucket(BaseModel): + id: str + name: str + location: str + status: str = "" + versioning_status: str = "Off" + is_public_access_enabled: bool = False + is_encryption_enabled: bool = False + is_lock_enabled: bool = False + lifecycle_configuration_status: str = "" + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name + + +class EfsVolume(BaseModel): + id: str + name: str + location: str + status: str = "" + vpc_id: str = "" + is_backup_enabled: bool = False + is_all_vpc_resources_allowed: bool = False + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name + + +class EpfsVolume(BaseModel): + id: str + name: str + location: str + vpc_network_id: str = "" + vpc_name: str = "" + deleted: bool = False + + @property + def resource_id(self) -> str: + return self.id + + @property + def resource_name(self) -> str: + return self.name diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index cd602a3a615..7b70fc5bb6d 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,e2e,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -39,6 +39,7 @@ def mock_get_available_providers(): "cloudflare", "openstack", "stackit", + "e2e", ] diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index 0f1cb14e258..168e4185119 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -1546,6 +1546,40 @@ def test_generate_output_stackit(self): assert finding_output.status == Status.PASS assert finding_output.muted is False + @patch( + "prowler.lib.outputs.finding.get_check_compliance", + new=mock_get_check_compliance, + ) + def test_generate_output_e2e(self): + provider = MagicMock() + provider.type = "e2e" + provider.identity.project_id = 12345 + + check_output = MagicMock() + check_output.resource_id = "test_resource_id" + check_output.resource_name = "test_resource_name" + check_output.resource_details = "" + check_output.location = "Delhi" + check_output.status = Status.PASS + check_output.status_extended = "mock_status_extended" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="e2e") + check_output.resource = {} + check_output.compliance = {} + + output_options = MagicMock() + output_options.unix_timestamp = True + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert isinstance(finding_output, Finding) + assert finding_output.auth_method == "api_key_and_bearer_token" + assert finding_output.account_uid == "12345" + assert finding_output.account_name == "12345" + assert finding_output.resource_name == "test_resource_name" + assert finding_output.resource_uid == "test_resource_id" + assert finding_output.region == "Delhi" + def test_transform_api_finding_stackit(self): provider = MagicMock() provider.type = "stackit" diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 536e40f8086..9e9b652474e 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -999,6 +999,23 @@ def test_stackit_get_assessment_summary_without_project_name(self): assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary assert "Project Name:" not in summary + def test_e2e_get_assessment_summary(self): + """Test E2E Cloud HTML assessment summary shows project and locations.""" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "e2e" + provider.identity.project_id = 12345 + provider.identity.locations = ["Delhi", "Chennai"] + + summary = output.get_assessment_summary(provider) + + assert "E2E Cloud Assessment Summary" in summary + assert "Project ID: 12345" in summary + assert "Locations: Delhi, Chennai" in summary + assert "API Key + Bearer Token" in summary + def test_process_markdown_bold_text(self): """Test that **text** is converted to text""" test_text = "This is **bold text** and this is **also bold**" diff --git a/tests/lib/outputs/outputs_test.py b/tests/lib/outputs/outputs_test.py index ca187178470..f5d8ae9cda3 100644 --- a/tests/lib/outputs/outputs_test.py +++ b/tests/lib/outputs/outputs_test.py @@ -1293,3 +1293,23 @@ def test_report_with_stackit_provider_fail(self): with mock.patch("builtins.print") as mocked_print: report([finding], provider, output_options) mocked_print.assert_called() + + def test_report_with_e2e_provider(self): + finding = MagicMock() + finding.status = "PASS" + finding.muted = False + finding.location = "Delhi" + finding.check_metadata.Provider = "e2e" + finding.status_extended = "Node has no public IP assigned" + + output_options = MagicMock() + output_options.verbose = True + output_options.status = ["PASS", "FAIL"] + output_options.fixer = False + + provider = MagicMock() + provider.type = "e2e" + + with mock.patch("builtins.print") as mocked_print: + report([finding], provider, output_options) + mocked_print.assert_called() diff --git a/tests/lib/outputs/summary_table_test.py b/tests/lib/outputs/summary_table_test.py index 0c842225cce..0d968834771 100644 --- a/tests/lib/outputs/summary_table_test.py +++ b/tests/lib/outputs/summary_table_test.py @@ -72,6 +72,34 @@ def test_stackit_summary_with_project_name(self, capsys): assert "Project" in captured.out assert "my-prod-env" in captured.out + def test_e2e_summary(self, capsys): + provider = SimpleNamespace( + type="e2e", + identity=SimpleNamespace(project_id=12345), + ) + output_options = SimpleNamespace( + output_directory="out", + output_filename="report", + output_modes=[], + ) + findings = [ + SimpleNamespace( + status="PASS", + muted=False, + check_metadata=SimpleNamespace( + ServiceName="node", + Provider="e2e", + Severity="high", + ), + ) + ] + + display_summary_table(findings, provider, output_options) + + captured = capsys.readouterr() + assert "Project" in captured.out + assert "12345" in captured.out + def test_stackit_summary_with_project_id_only(self, capsys): provider = SimpleNamespace( type="stackit", diff --git a/tests/providers/e2e/e2e_checks_test.py b/tests/providers/e2e/e2e_checks_test.py new file mode 100644 index 00000000000..fd9b702d2d8 --- /dev/null +++ b/tests/providers/e2e/e2e_checks_test.py @@ -0,0 +1,844 @@ +from prowler.providers.e2e.services.database.database_service import ( + DatabaseCluster, + DatabaseInstance, +) +from prowler.providers.e2e.services.loadbalancer.loadbalancer_service import LoadBalancer +from prowler.providers.e2e.services.network.network_service import ( + ReservedIp, + Vpc, + VpcTunnel, +) +from prowler.providers.e2e.services.node.nodes_service import Node +from prowler.providers.e2e.services.securitygroup.securitygroup_service import ( + NodeSecurityGroup, + SecurityGroupResource, + SecurityGroupRule, +) +from prowler.providers.e2e.services.storage.storage_service import ( + BlockVolume, + EfsVolume, + StorageBucket, +) +from tests.providers.e2e.e2e_fixtures import run_e2e_check + + +def _database_client_path(check_name: str) -> str: + return ( + f"prowler.providers.e2e.services.database.{check_name}.{check_name}" + ".database_client" + ) + + +def _network_client_path(check_name: str) -> str: + return ( + f"prowler.providers.e2e.services.network.{check_name}.{check_name}" + ".network_client" + ) + + +def _node_client_path(check_name: str) -> str: + return ( + f"prowler.providers.e2e.services.node.{check_name}.{check_name}.nodes_client" + ) + + +def _storage_client_path(check_name: str) -> str: + return ( + f"prowler.providers.e2e.services.storage.{check_name}.{check_name}" + ".storage_client" + ) + + +def _loadbalancer_client_path(check_name: str) -> str: + return ( + f"prowler.providers.e2e.services.loadbalancer.{check_name}.{check_name}" + ".loadbalancer_client" + ) + + +def _securitygroup_client_path(check_name: str) -> str: + return ( + f"prowler.providers.e2e.services.securitygroup.{check_name}.{check_name}" + ".securitygroup_client" + ) + + +class TestDatabaseChecks: + def test_database_cluster_backup_enabled(self): + check = "database_cluster_backup_enabled" + resources = [ + DatabaseCluster( + id="1", + name="ok", + location="Delhi", + backup_enabled=True, + ), + DatabaseCluster( + id="2", + name="bad", + location="Delhi", + backup_enabled=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.database.{check}.{check}", + _database_client_path(check), + "clusters", + resources, + ) + assert len(findings) == 2 + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_database_cluster_default_admin_username(self): + check = "database_cluster_default_admin_username" + resources = [ + DatabaseCluster( + id="1", + name="ok", + location="Delhi", + master_username="dbadmin", + ), + DatabaseCluster( + id="2", + name="bad", + location="Delhi", + master_username="admin", + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.database.{check}.{check}", + _database_client_path(check), + "clusters", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_database_cluster_ip_whitelist_configured(self): + check = "database_cluster_ip_whitelist_configured" + resources = [ + DatabaseCluster( + id="1", + name="ok", + location="Delhi", + master_has_public_ip=True, + whitelisted_ips=["203.0.113.0/24"], + ), + DatabaseCluster( + id="2", + name="bad", + location="Delhi", + master_has_public_ip=True, + whitelisted_ips=[], + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.database.{check}.{check}", + _database_client_path(check), + "clusters", + resources, + ) + assert len(findings) == 2 + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_database_cluster_public_ip_not_assigned(self): + check = "database_cluster_public_ip_not_assigned" + resources = [ + DatabaseCluster( + id="1", + name="ok", + location="Delhi", + master_has_public_ip=False, + ), + DatabaseCluster( + id="2", + name="bad", + location="Delhi", + master_has_public_ip=True, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.database.{check}.{check}", + _database_client_path(check), + "clusters", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_database_cluster_running(self): + check = "database_cluster_running" + resources = [ + DatabaseCluster( + id="1", + name="ok", + location="Delhi", + status="RUNNING", + ), + DatabaseCluster( + id="2", + name="bad", + location="Delhi", + status="STOPPED", + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.database.{check}.{check}", + _database_client_path(check), + "clusters", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_database_replica_public_ip_not_assigned(self): + check = "database_replica_public_ip_not_assigned" + resources = [ + DatabaseInstance( + id="1", + name="ok", + cluster_id="c1", + cluster_name="cluster", + location="Delhi", + role="replica", + has_public_ip=False, + ), + DatabaseInstance( + id="2", + name="bad", + cluster_id="c1", + cluster_name="cluster", + location="Delhi", + role="replica", + has_public_ip=True, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.database.{check}.{check}", + _database_client_path(check), + "instances", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + +class TestNetworkChecks: + def test_network_reserveip_floating_ip_unattached(self): + check = "network_reserveip_floating_ip_unattached" + resources = [ + ReservedIp( + reserve_id="1", + ip_address="1.2.3.4", + location="Delhi", + reserved_type="FloatingIP", + status="Attached", + floating_ip_attached_nodes_count=1, + ), + ReservedIp( + reserve_id="2", + ip_address="5.6.7.8", + location="Delhi", + reserved_type="FloatingIP", + status="Available", + floating_ip_attached_nodes_count=0, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.network.{check}.{check}", + _network_client_path(check), + "reserved_ips", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_network_reserveip_orphaned_public_ip(self): + check = "network_reserveip_orphaned_public_ip" + resources = [ + ReservedIp( + reserve_id="1", + ip_address="1.2.3.4", + location="Delhi", + reserved_type="PublicIP", + status="Attached", + vm_id=123, + ), + ReservedIp( + reserve_id="2", + ip_address="5.6.7.8", + location="Delhi", + reserved_type="PublicIP", + status="Available", + vm_id=None, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.network.{check}.{check}", + _network_client_path(check), + "reserved_ips", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_network_vpc_has_attached_nodes(self): + check = "network_vpc_has_attached_nodes" + resources = [ + Vpc(network_id="1", name="ok", location="Delhi", vm_count=2), + Vpc(network_id="2", name="bad", location="Delhi", vm_count=0), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.network.{check}.{check}", + _network_client_path(check), + "vpcs", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_network_vpc_peering_external_peer_disabled(self): + check = "network_vpc_peering_external_peer_disabled" + resources = [ + VpcTunnel( + id="1", + name="ok", + location="Delhi", + local_vpc_network_id="vpc-1", + local_vpc_name="vpc", + is_peer_vpc_external=False, + ), + VpcTunnel( + id="2", + name="bad", + location="Delhi", + local_vpc_network_id="vpc-2", + local_vpc_name="vpc", + is_peer_vpc_external=True, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.network.{check}.{check}", + _network_client_path(check), + "vpc_tunnels", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + +class TestNodeChecks: + def test_node_accidental_protection_enabled(self): + check = "node_accidental_protection_enabled" + resources = [ + Node( + id="1", + name="ok", + status="Running", + location="Delhi", + vm_id="1", + is_accidental_protection=True, + ), + Node( + id="2", + name="bad", + status="Running", + location="Delhi", + vm_id="2", + is_accidental_protection=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.node.{check}.{check}", + _node_client_path(check), + "nodes", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_node_compliance_enabled(self): + check = "node_compliance_enabled" + resources = [ + Node( + id="1", + name="ok", + status="Running", + location="Delhi", + vm_id="1", + is_node_compliance=True, + ), + Node( + id="2", + name="bad", + status="Running", + location="Delhi", + vm_id="2", + is_node_compliance=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.node.{check}.{check}", + _node_client_path(check), + "nodes", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_node_encryption_enabled(self): + check = "node_encryption_enabled" + resources = [ + Node( + id="1", + name="ok", + status="Running", + location="Delhi", + vm_id="1", + is_encryption_enabled=True, + ), + Node( + id="2", + name="bad", + status="Running", + location="Delhi", + vm_id="2", + is_encryption_enabled=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.node.{check}.{check}", + _node_client_path(check), + "nodes", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_node_rescue_mode_disabled(self): + check = "node_rescue_mode_disabled" + resources = [ + Node( + id="1", + name="ok", + status="Running", + location="Delhi", + vm_id="1", + rescue_mode_status="Disabled", + ), + Node( + id="2", + name="bad", + status="Running", + location="Delhi", + vm_id="2", + rescue_mode_status="Enabled", + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.node.{check}.{check}", + _node_client_path(check), + "nodes", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_node_vpc_attached(self): + check = "node_vpc_attached" + resources = [ + Node( + id="1", + name="ok", + status="Running", + location="Delhi", + vm_id="1", + is_vpc_attached=True, + ), + Node( + id="2", + name="bad", + status="Running", + location="Delhi", + vm_id="2", + is_vpc_attached=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.node.{check}.{check}", + _node_client_path(check), + "nodes", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + +class TestStorageChecks: + def test_storage_block_volume_not_orphaned(self): + check = "storage_block_volume_not_orphaned" + resources = [ + BlockVolume( + id="1", + name="ok", + location="Delhi", + is_attached=True, + ), + BlockVolume( + id="2", + name="bad", + location="Delhi", + status="Available", + is_attached=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.storage.{check}.{check}", + _storage_client_path(check), + "block_volumes", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_storage_bucket_encryption_enabled(self): + check = "storage_bucket_encryption_enabled" + resources = [ + StorageBucket( + id="1", + name="ok", + location="Delhi", + is_encryption_enabled=True, + ), + StorageBucket( + id="2", + name="bad", + location="Delhi", + is_encryption_enabled=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.storage.{check}.{check}", + _storage_client_path(check), + "buckets", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_storage_bucket_lifecycle_configured(self): + check = "storage_bucket_lifecycle_configured" + resources = [ + StorageBucket( + id="1", + name="ok", + location="Delhi", + lifecycle_configuration_status="Configured", + ), + StorageBucket( + id="2", + name="bad", + location="Delhi", + lifecycle_configuration_status="Disabled", + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.storage.{check}.{check}", + _storage_client_path(check), + "buckets", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_storage_bucket_lock_enabled(self): + check = "storage_bucket_lock_enabled" + resources = [ + StorageBucket( + id="1", + name="ok", + location="Delhi", + is_lock_enabled=True, + ), + StorageBucket( + id="2", + name="bad", + location="Delhi", + is_lock_enabled=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.storage.{check}.{check}", + _storage_client_path(check), + "buckets", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_storage_bucket_public_access_disabled(self): + check = "storage_bucket_public_access_disabled" + resources = [ + StorageBucket( + id="1", + name="ok", + location="Delhi", + is_public_access_enabled=False, + ), + StorageBucket( + id="2", + name="bad", + location="Delhi", + is_public_access_enabled=True, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.storage.{check}.{check}", + _storage_client_path(check), + "buckets", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_storage_bucket_versioning_enabled(self): + check = "storage_bucket_versioning_enabled" + resources = [ + StorageBucket( + id="1", + name="ok", + location="Delhi", + versioning_status="Enabled", + ), + StorageBucket( + id="2", + name="bad", + location="Delhi", + versioning_status="Off", + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.storage.{check}.{check}", + _storage_client_path(check), + "buckets", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_storage_efs_vpc_access_restricted(self): + check = "storage_efs_vpc_access_restricted" + resources = [ + EfsVolume( + id="1", + name="ok", + location="Delhi", + is_all_vpc_resources_allowed=False, + ), + EfsVolume( + id="2", + name="bad", + location="Delhi", + is_all_vpc_resources_allowed=True, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.storage.{check}.{check}", + _storage_client_path(check), + "efs_volumes", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + +class TestLoadBalancerChecks: + def test_loadbalancer_alb_https_uses_ssl_certificate(self): + check = "loadbalancer_alb_https_uses_ssl_certificate" + resources = [ + LoadBalancer( + id="1", + name="ok", + location="Delhi", + lb_mode="HTTPS", + ssl_certificate_id="cert-1", + ), + LoadBalancer( + id="2", + name="bad", + location="Delhi", + lb_mode="HTTPS", + ssl_certificate_id=None, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.loadbalancer.{check}.{check}", + _loadbalancer_client_path(check), + "load_balancers", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_loadbalancer_backend_health_check_enabled(self): + check = "loadbalancer_backend_health_check_enabled" + resources = [ + LoadBalancer( + id="1", + name="ok", + location="Delhi", + lb_mode="HTTP", + backends=[{"http_check": True}], + ), + LoadBalancer( + id="2", + name="bad", + location="Delhi", + lb_mode="HTTP", + backends=[{}], + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.loadbalancer.{check}.{check}", + _loadbalancer_client_path(check), + "load_balancers", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_loadbalancer_bitninja_enabled(self): + check = "loadbalancer_bitninja_enabled" + resources = [ + LoadBalancer( + id="1", + name="ok", + location="Delhi", + enable_bitninja=True, + ), + LoadBalancer( + id="2", + name="bad", + location="Delhi", + enable_bitninja=False, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.loadbalancer.{check}.{check}", + _loadbalancer_client_path(check), + "load_balancers", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + +class TestSecurityGroupChecks: + def test_securitygroup_no_all_traffic_rule(self): + check = "securitygroup_no_all_traffic_rule" + resources = [ + SecurityGroupResource( + id="1", + name="ok", + location="Delhi", + is_all_traffic_rule=False, + ), + SecurityGroupResource( + id="2", + name="bad", + location="Delhi", + is_all_traffic_rule=True, + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.securitygroup.{check}.{check}", + _securitygroup_client_path(check), + "security_groups", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_securitygroup_no_inbound_any_all_ports(self): + check = "securitygroup_no_inbound_any_all_ports" + permissive_rule = SecurityGroupRule( + id="1", + rule_type="inbound", + protocol_name="all", + port_range="", + network="any", + network_cidr="", + ) + safe_rule = SecurityGroupRule( + id="2", + rule_type="inbound", + protocol_name="tcp", + port_range="443", + network="203.0.113.0/24", + network_cidr="203.0.113.0/24", + ) + resources = [ + SecurityGroupResource( + id="1", + name="ok", + location="Delhi", + rules=[safe_rule], + ), + SecurityGroupResource( + id="2", + name="bad", + location="Delhi", + rules=[permissive_rule], + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.securitygroup.{check}.{check}", + _securitygroup_client_path(check), + "security_groups", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_securitygroup_restrictive_default(self): + check = "securitygroup_restrictive_default" + permissive_rule = SecurityGroupRule( + id="1", + rule_type="inbound", + protocol_name="all", + port_range="", + network="any", + network_cidr="", + ) + resources = [ + NodeSecurityGroup( + node_id="1", + node_name="ok-node", + vm_id="vm-1", + location="Delhi", + security_group_id="sg-1", + name="custom", + is_default=False, + rules=[], + ), + NodeSecurityGroup( + node_id="2", + node_name="bad-node", + vm_id="vm-2", + location="Delhi", + security_group_id="sg-2", + name="default", + is_default=True, + rules=[permissive_rule], + ), + ] + findings = run_e2e_check( + f"prowler.providers.e2e.services.securitygroup.{check}.{check}", + _securitygroup_client_path(check), + "node_security_groups", + resources, + ) + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" diff --git a/tests/providers/e2e/e2e_fixtures.py b/tests/providers/e2e/e2e_fixtures.py new file mode 100644 index 00000000000..dadf6917bde --- /dev/null +++ b/tests/providers/e2e/e2e_fixtures.py @@ -0,0 +1,52 @@ +import importlib +import sys +from unittest.mock import MagicMock, patch + +from prowler.providers.e2e.e2e_provider import E2eProvider +from prowler.providers.e2e.models import E2eIdentityInfo, E2eSession + + +def run_e2e_check( + check_module_path: str, client_patch_path: str, client_attr: str, resources: list +): + """Execute an E2E check with mocked client resources.""" + check_class_name = check_module_path.rsplit(".", 1)[-1] + client = MagicMock() + setattr(client, client_attr, resources) + + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_e2e_provider(), + ): + if check_module_path in sys.modules: + module = importlib.reload(sys.modules[check_module_path]) + else: + module = importlib.import_module(check_module_path) + + with patch(client_patch_path, new=client): + return getattr(module, check_class_name)().execute() + + +def set_mocked_e2e_provider( + project_id: int = 12345, + locations: list[str] | None = None, + audit_config: dict | None = None, + fixer_config: dict | None = None, +): + """Create a mocked E2E provider for tests without network calls.""" + provider = MagicMock(spec=E2eProvider) + provider.type = "e2e" + provider.audit_config = audit_config or {} + provider.fixer_config = fixer_config or {} + provider.identity = E2eIdentityInfo( + project_id=project_id, + locations=locations or ["Delhi"], + ) + provider.session = E2eSession( + api_key="test-api-key", + auth_token="test-auth-token", + project_id=project_id, + locations=locations or ["Delhi"], + http_session=MagicMock(), + ) + return provider diff --git a/tests/providers/e2e/e2e_provider_test.py b/tests/providers/e2e/e2e_provider_test.py new file mode 100644 index 00000000000..322281ed667 --- /dev/null +++ b/tests/providers/e2e/e2e_provider_test.py @@ -0,0 +1,81 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.common.models import Connection +from prowler.providers.e2e.e2e_provider import E2eProvider +from prowler.providers.e2e.exceptions.exceptions import E2eCredentialsError + + +class TestE2eProvider: + @patch("prowler.providers.e2e.e2e_provider.load_and_validate_config_file") + def test_e2e_provider_init_success(self, mock_load_config): + mock_load_config.return_value = {} + + provider = E2eProvider( + api_key="test-api-key", + auth_token="test-auth-token", + project_id="12345", + locations=["Delhi"], + ) + + assert provider.session.api_key == "test-api-key" + assert provider.session.auth_token == "test-auth-token" + assert provider.session.project_id == 12345 + assert provider.identity.project_id == 12345 + assert provider.session.locations == ["Delhi"] + assert provider.session.http_session.headers["Authorization"] == ( + "Bearer test-auth-token" + ) + + @patch("prowler.providers.e2e.e2e_provider.load_and_validate_config_file") + def test_e2e_provider_init_missing_credentials(self, mock_load_config): + mock_load_config.return_value = {} + + with pytest.raises(E2eCredentialsError): + E2eProvider(api_key="", auth_token="token", project_id="1") + + @patch.dict( + os.environ, + { + "E2E_API_KEY": "env-api-key", + "E2E_AUTH_TOKEN": "env-auth-token", + "E2E_PROJECT_ID": "99", + "E2E_LOCATION": "Chennai", + }, + clear=False, + ) + @patch("prowler.providers.e2e.e2e_provider.load_and_validate_config_file") + def test_e2e_provider_init_from_env(self, mock_load_config): + mock_load_config.return_value = {} + + provider = E2eProvider() + + assert provider.session.api_key == "env-api-key" + assert provider.session.project_id == 99 + assert provider.session.locations == ["Chennai"] + + @patch("prowler.providers.e2e.e2e_provider.E2eProvider.setup_session") + def test_e2e_test_connection_success(self, mock_setup_session): + mock_session = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_session.http_session.get.return_value = mock_response + mock_session.api_key = "key" + mock_session.project_id = 1 + mock_session.base_url = "https://api.e2enetworks.com/myaccount/api/v1" + mock_setup_session.return_value = mock_session + + result = E2eProvider.test_connection( + api_key="key", + auth_token="token", + project_id=1, + locations=["Delhi"], + ) + + assert result == Connection(is_connected=True) + + def test_e2e_test_connection_missing_credentials(self): + with pytest.raises(E2eCredentialsError): + E2eProvider.test_connection(api_key="", auth_token="", project_id=None) diff --git a/tests/providers/e2e/lib/arguments/e2e_arguments_test.py b/tests/providers/e2e/lib/arguments/e2e_arguments_test.py new file mode 100644 index 00000000000..807d0b0c562 --- /dev/null +++ b/tests/providers/e2e/lib/arguments/e2e_arguments_test.py @@ -0,0 +1,38 @@ +from unittest.mock import MagicMock + +from prowler.providers.e2e.lib.arguments.arguments import validate_arguments + + +class TestE2eArguments: + def test_validate_arguments_success(self): + arguments = MagicMock() + arguments.e2e_api_key = "key" + arguments.e2e_auth_token = "token" + arguments.e2e_project_id = "123" + + valid, message = validate_arguments(arguments) + + assert valid is True + assert message == "" + + def test_validate_arguments_missing_project_id(self): + arguments = MagicMock() + arguments.e2e_api_key = "key" + arguments.e2e_auth_token = "token" + arguments.e2e_project_id = None + + valid, message = validate_arguments(arguments) + + assert valid is False + assert "project ID" in message + + def test_validate_arguments_invalid_project_id(self): + arguments = MagicMock() + arguments.e2e_api_key = "key" + arguments.e2e_auth_token = "token" + arguments.e2e_project_id = "abc" + + valid, message = validate_arguments(arguments) + + assert valid is False + assert "integer" in message diff --git a/tests/providers/e2e/services/database/__init__.py b/tests/providers/e2e/services/database/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/providers/e2e/services/database/database_cluster_ssl_enabled_test.py b/tests/providers/e2e/services/database/database_cluster_ssl_enabled_test.py new file mode 100644 index 00000000000..b26a5fe9a06 --- /dev/null +++ b/tests/providers/e2e/services/database/database_cluster_ssl_enabled_test.py @@ -0,0 +1,43 @@ +from unittest import mock + +from prowler.providers.e2e.services.database.database_service import DatabaseCluster +from tests.providers.e2e.e2e_fixtures import set_mocked_e2e_provider + + +class TestDatabaseClusterSslEnabledCheck: + def test_pass_and_fail(self): + database_client = mock.MagicMock() + database_client.clusters = [ + DatabaseCluster( + id="1", + name="secure-db", + location="Delhi", + master_ssl_enabled=True, + ), + DatabaseCluster( + id="2", + name="insecure-db", + location="Delhi", + master_ssl_enabled=False, + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_e2e_provider(), + ), + mock.patch( + "prowler.providers.e2e.services.database.database_cluster_ssl_enabled.database_cluster_ssl_enabled.database_client", + new=database_client, + ), + ): + from prowler.providers.e2e.services.database.database_cluster_ssl_enabled.database_cluster_ssl_enabled import ( + database_cluster_ssl_enabled, + ) + + findings = database_cluster_ssl_enabled().execute() + + assert len(findings) == 2 + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" diff --git a/tests/providers/e2e/services/database/e2e_database_service_test.py b/tests/providers/e2e/services/database/e2e_database_service_test.py new file mode 100644 index 00000000000..da97802eff8 --- /dev/null +++ b/tests/providers/e2e/services/database/e2e_database_service_test.py @@ -0,0 +1,70 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.e2e.services.database.database_service import Database + + +class TestDatabaseService: + @patch( + "prowler.providers.e2e.services.database.database_service.E2eService.__init__" + ) + def test_fetch_clusters_enriches_detail(self, mock_super_init): + mock_super_init.return_value = None + + provider = MagicMock() + provider.session.locations = ["Delhi"] + service = Database.__new__(Database) + service.provider = provider + service.client = MagicMock() + service.clusters = [] + service.instances = [] + + service.client.get_data.side_effect = [ + [ + { + "id": 5276, + "name": "E2E-DBaaS-1", + "status": "RUNNING", + "software": {"name": "MySQL", "version": "8.0"}, + "master_node": { + "instance_id": 10650, + "node_name": "E2E-DBaaS-1-Node-1", + "public_ip_address": "164.52.1.1", + "ssl": True, + "database": {"username": "dbadmin"}, + }, + } + ], + { + "id": 5276, + "name": "E2E-DBaaS-1", + "status": "RUNNING", + "backup_enabled": True, + "whitelisted_ips": ["203.0.113.0/24"], + "master_node": { + "instance_id": 10650, + "node_name": "E2E-DBaaS-1-Node-1", + "public_ip_address": "164.52.1.1", + "ssl": True, + "database": {"username": "dbadmin"}, + }, + "slave_nodes": [ + { + "instance_id": 10651, + "node_name": "E2E-DBaaS-1-Replica-1", + "public_ip_address": None, + "database": {"username": "dbadmin"}, + } + ], + }, + ] + + service._fetch_clusters() + + assert len(service.clusters) == 1 + cluster = service.clusters[0] + assert cluster.backup_enabled is True + assert cluster.master_ssl_enabled is True + assert cluster.master_has_public_ip is True + assert cluster.master_username == "dbadmin" + assert len(service.instances) == 2 + assert service.instances[1].role == "replica" diff --git a/tests/providers/e2e/services/network/__init__.py b/tests/providers/e2e/services/network/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/providers/e2e/services/network/e2e_network_service_test.py b/tests/providers/e2e/services/network/e2e_network_service_test.py new file mode 100644 index 00000000000..7dfebb1a814 --- /dev/null +++ b/tests/providers/e2e/services/network/e2e_network_service_test.py @@ -0,0 +1,72 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.e2e.services.network.network_service import Network, Vpc + + +class TestNetworkService: + @patch("prowler.providers.e2e.services.network.network_service.E2eService.__init__") + def test_fetch_vpcs_and_tunnels(self, mock_super_init): + mock_super_init.return_value = None + + provider = MagicMock() + provider.session.locations = ["Delhi"] + service = Network.__new__(Network) + service.provider = provider + service.client = MagicMock() + service.vpcs = [] + service.reserved_ips = [] + service.vpc_tunnels = [] + + service.client.paginate.return_value = [ + { + "network_id": 100, + "name": "VPC-100", + "is_active": True, + "state": "Active", + "ipv4_cidr": "10.0.0.0/23", + "vm_count": 2, + "gateway_node": {"node_id": 1, "ip_address_public": "1.2.3.4"}, + } + ] + service.client.get_data.side_effect = [ + [ + { + "reserve_id": 10, + "ip_address": "164.52.1.1", + "status": "Attached", + "reserved_type": "FloatingIP", + "vm_id": 55, + "floating_ip_attached_nodes": [{"id": 1}], + } + ], + [ + { + "id": 5, + "name": "peer-tunnel", + "status": "ACTIVE", + "is_peer_vpc_external": True, + } + ], + ] + + service._fetch_vpcs() + service._fetch_reserved_ips() + service._fetch_vpc_tunnels() + + assert len(service.vpcs) == 1 + assert service.vpcs[0].vm_count == 2 + assert len(service.reserved_ips) == 1 + assert service.reserved_ips[0].floating_ip_attached_nodes_count == 1 + assert len(service.vpc_tunnels) == 1 + assert service.vpc_tunnels[0].is_peer_vpc_external is True + + +class TestVpcModel: + def test_resource_properties(self): + vpc = Vpc( + network_id="100", + name="VPC-100", + location="Delhi", + ) + assert vpc.resource_id == "100" + assert vpc.resource_name == "VPC-100" diff --git a/tests/providers/e2e/services/network/network_vpc_is_active_test.py b/tests/providers/e2e/services/network/network_vpc_is_active_test.py new file mode 100644 index 00000000000..84d05851e80 --- /dev/null +++ b/tests/providers/e2e/services/network/network_vpc_is_active_test.py @@ -0,0 +1,67 @@ +from unittest import mock + +from prowler.providers.e2e.services.network.network_service import Vpc +from tests.providers.e2e.e2e_fixtures import set_mocked_e2e_provider + + +class TestNetworkVpcIsActiveCheck: + def test_pass_and_fail(self): + network_client = mock.MagicMock() + network_client.vpcs = [ + Vpc( + network_id="1", + name="active-vpc", + location="Delhi", + is_active=True, + state="Active", + ), + Vpc( + network_id="2", + name="inactive-vpc", + location="Delhi", + is_active=False, + state="Inactive", + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_e2e_provider(), + ), + mock.patch( + "prowler.providers.e2e.services.network.network_vpc_is_active.network_vpc_is_active.network_client", + new=network_client, + ), + ): + from prowler.providers.e2e.services.network.network_vpc_is_active.network_vpc_is_active import ( + network_vpc_is_active, + ) + + findings = network_vpc_is_active().execute() + + assert len(findings) == 2 + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_no_resources(self): + network_client = mock.MagicMock() + network_client.vpcs = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_e2e_provider(), + ), + mock.patch( + "prowler.providers.e2e.services.network.network_vpc_is_active.network_vpc_is_active.network_client", + new=network_client, + ), + ): + from prowler.providers.e2e.services.network.network_vpc_is_active.network_vpc_is_active import ( + network_vpc_is_active, + ) + + findings = network_vpc_is_active().execute() + + assert findings == [] diff --git a/tests/providers/e2e/services/node/node_public_ip_not_assigned_test.py b/tests/providers/e2e/services/node/node_public_ip_not_assigned_test.py new file mode 100644 index 00000000000..fcd804c94e5 --- /dev/null +++ b/tests/providers/e2e/services/node/node_public_ip_not_assigned_test.py @@ -0,0 +1,69 @@ +from unittest import mock + +from prowler.providers.e2e.services.node.nodes_service import Node +from tests.providers.e2e.e2e_fixtures import set_mocked_e2e_provider + + +class TestNodePublicIpCheck: + def test_empty_nodes(self): + nodes_client = mock.MagicMock() + nodes_client.nodes = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_e2e_provider(), + ), + mock.patch( + "prowler.providers.e2e.services.node.node_public_ip_not_assigned.node_public_ip_not_assigned.nodes_client", + new=nodes_client, + ), + ): + from prowler.providers.e2e.services.node.node_public_ip_not_assigned.node_public_ip_not_assigned import ( + node_public_ip_not_assigned, + ) + + check = node_public_ip_not_assigned() + assert check.execute() == [] + + def test_pass_and_fail(self): + nodes_client = mock.MagicMock() + nodes_client.nodes = [ + Node( + id="1", + name="private-node", + status="Running", + location="Delhi", + vm_id="1", + has_public_ip=False, + ), + Node( + id="2", + name="public-node", + status="Running", + location="Delhi", + vm_id="2", + has_public_ip=True, + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_e2e_provider(), + ), + mock.patch( + "prowler.providers.e2e.services.node.node_public_ip_not_assigned.node_public_ip_not_assigned.nodes_client", + new=nodes_client, + ), + ): + from prowler.providers.e2e.services.node.node_public_ip_not_assigned.node_public_ip_not_assigned import ( + node_public_ip_not_assigned, + ) + + check = node_public_ip_not_assigned() + findings = check.execute() + + assert len(findings) == 2 + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" diff --git a/tests/providers/e2e/services/node/nodes_service_test.py b/tests/providers/e2e/services/node/nodes_service_test.py new file mode 100644 index 00000000000..c9deebba3f5 --- /dev/null +++ b/tests/providers/e2e/services/node/nodes_service_test.py @@ -0,0 +1,90 @@ +import pytest + +from unittest.mock import MagicMock, patch + +from prowler.providers.e2e.services.node.nodes_service import Node, Nodes + + +class TestNodesService: + @patch("prowler.providers.e2e.services.node.nodes_service.E2eService.__init__") + def test_fetch_nodes_enriches_detail(self, mock_super_init): + mock_super_init.return_value = None + + provider = MagicMock() + provider.session.locations = ["Delhi"] + service = Nodes.__new__(Nodes) + service.provider = provider + service.client = MagicMock() + service.nodes = [] + + service.client.get_data.side_effect = [ + [ + { + "id": 101, + "name": "node-1", + "status": "Running", + "public_ip_address": "1.2.3.4", + "is_accidental_protection": True, + "isEncryptionEnabled": True, + "is_locked": False, + "rescue_mode_status": "Disabled", + } + ], + { + "vm_id": 555, + "is_node_compliance": True, + "is_vpc_attached": True, + }, + ] + + service._fetch_nodes() + + assert len(service.nodes) == 1 + node = service.nodes[0] + assert node.id == "101" + assert node.vm_id == "555" + assert node.has_public_ip is True + assert node.is_node_compliance is True + assert node.is_vpc_attached is True + + +class TestNodeCheckLogic: + def test_node_public_ip_detection(self): + public_node = Node( + id="1", + name="public", + status="Running", + location="Delhi", + vm_id="1", + has_public_ip=True, + ) + private_node = Node( + id="2", + name="private", + status="Running", + location="Delhi", + vm_id="2", + has_public_ip=False, + ) + + assert public_node.has_public_ip is True + assert private_node.has_public_ip is False + + +class TestHasPublicIp: + @pytest.mark.parametrize( + "public_ip_address,expected", + [ + (None, False), + ("", False), + ("[]", False), + ("null", False), + ("None", False), + ("1.2.3.4", True), + (" 10.0.0.1 ", True), + ], + ) + def test_has_public_ip_normalization(self, public_ip_address, expected): + from prowler.providers.e2e.services.node.nodes_service import _has_public_ip + + assert _has_public_ip(public_ip_address) is expected diff --git a/tests/providers/e2e/services/storage/__init__.py b/tests/providers/e2e/services/storage/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/providers/e2e/services/storage/e2e_storage_service_test.py b/tests/providers/e2e/services/storage/e2e_storage_service_test.py new file mode 100644 index 00000000000..37529dc6e7e --- /dev/null +++ b/tests/providers/e2e/services/storage/e2e_storage_service_test.py @@ -0,0 +1,49 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.e2e.services.storage.storage_service import Storage + + +class TestStorageService: + @patch("prowler.providers.e2e.services.storage.storage_service.E2eService.__init__") + def test_fetch_efs_and_epfs(self, mock_super_init): + mock_super_init.return_value = None + + provider = MagicMock() + provider.session.locations = ["Delhi"] + service = Storage.__new__(Storage) + service.provider = provider + service.client = MagicMock() + service.block_volumes = [] + service.buckets = [] + service.efs_volumes = [] + service.epfs_volumes = [] + + service.client.paginate.return_value = [ + { + "id": 1396, + "name": "sfs-993", + "status": "Available", + "vpc_id": 6882, + "is_backup_enabled": True, + "is_all_vpc_resources_allowed": False, + } + ] + service.client.get.return_value = { + "data": [ + { + "id": 145, + "name": "epfs-1", + "deleted": False, + "vpc": {"network_id": 34872, "name": "VPC-717"}, + } + ], + "total_page_number": 1, + } + + service._fetch_efs_volumes() + service._fetch_epfs_volumes() + + assert len(service.efs_volumes) == 1 + assert service.efs_volumes[0].is_backup_enabled is True + assert len(service.epfs_volumes) == 1 + assert service.epfs_volumes[0].vpc_network_id == "34872" diff --git a/tests/providers/e2e/services/storage/storage_efs_backup_enabled_test.py b/tests/providers/e2e/services/storage/storage_efs_backup_enabled_test.py new file mode 100644 index 00000000000..ff15a687922 --- /dev/null +++ b/tests/providers/e2e/services/storage/storage_efs_backup_enabled_test.py @@ -0,0 +1,65 @@ +from unittest import mock + +from prowler.providers.e2e.services.storage.storage_service import EfsVolume +from tests.providers.e2e.e2e_fixtures import set_mocked_e2e_provider + + +class TestStorageEfsBackupEnabledCheck: + def test_pass_and_fail(self): + storage_client = mock.MagicMock() + storage_client.efs_volumes = [ + EfsVolume( + id="1", + name="efs-ok", + location="Delhi", + is_backup_enabled=True, + ), + EfsVolume( + id="2", + name="efs-bad", + location="Delhi", + is_backup_enabled=False, + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_e2e_provider(), + ), + mock.patch( + "prowler.providers.e2e.services.storage.storage_efs_backup_enabled.storage_efs_backup_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.e2e.services.storage.storage_efs_backup_enabled.storage_efs_backup_enabled import ( + storage_efs_backup_enabled, + ) + + findings = storage_efs_backup_enabled().execute() + + assert len(findings) == 2 + assert findings[0].status == "PASS" + assert findings[1].status == "FAIL" + + def test_no_resources(self): + storage_client = mock.MagicMock() + storage_client.efs_volumes = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_e2e_provider(), + ), + mock.patch( + "prowler.providers.e2e.services.storage.storage_efs_backup_enabled.storage_efs_backup_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.e2e.services.storage.storage_efs_backup_enabled.storage_efs_backup_enabled import ( + storage_efs_backup_enabled, + ) + + findings = storage_efs_backup_enabled().execute() + + assert findings == []