From 8a1d7bcd6b0013009211e7cc253679ac2bc55b1e Mon Sep 17 00:00:00 2001 From: varunmamillapalli Date: Mon, 22 Jun 2026 05:19:20 -0400 Subject: [PATCH] feat(linode): add provider with administration compute and networking services (#11633) Co-authored-by: Daniel Barranquero Co-authored-by: Hugo P.Brito --- .github/labeler.yml | 5 + .github/workflows/sdk-tests.yml | 24 ++ README.md | 1 + docs/docs.json | 7 + .../providers/linode/authentication.mdx | 97 +++++ .../linode/getting-started-linode.mdx | 61 ++++ prowler/CHANGELOG.md | 1 + prowler/__main__.py | 5 + prowler/compliance/linode/__init__.py | 0 prowler/config/config.py | 1 + prowler/config/linode_mutelist_example.yaml | 18 + prowler/lib/check/check.py | 4 + prowler/lib/check/models.py | 31 ++ prowler/lib/cli/parser.py | 8 +- prowler/lib/outputs/finding.py | 18 + prowler/lib/outputs/html/html.py | 57 +++ prowler/lib/outputs/outputs.py | 2 + prowler/lib/outputs/summary_table.py | 5 + prowler/providers/common/provider.py | 10 + prowler/providers/linode/__init__.py | 0 .../providers/linode/exceptions/__init__.py | 0 .../providers/linode/exceptions/exceptions.py | 106 ++++++ prowler/providers/linode/lib/__init__.py | 0 .../linode/lib/arguments/__init__.py | 0 .../linode/lib/arguments/arguments.py | 24 ++ .../providers/linode/lib/mutelist/__init__.py | 0 .../providers/linode/lib/mutelist/mutelist.py | 30 ++ .../providers/linode/lib/service/__init__.py | 0 .../providers/linode/lib/service/service.py | 86 +++++ prowler/providers/linode/linode_provider.py | 343 ++++++++++++++++++ prowler/providers/linode/models.py | 38 ++ prowler/providers/linode/services/__init__.py | 0 .../services/administration/__init__.py | 0 .../administration/administration_client.py | 6 + .../administration/administration_service.py | 46 +++ .../__init__.py | 0 ...inistration_user_2fa_enabled.metadata.json | 36 ++ .../administration_user_2fa_enabled.py | 41 +++ .../linode/services/compute/__init__.py | 0 .../linode/services/compute/compute_client.py | 4 + .../__init__.py | 0 ...ute_instance_backups_enabled.metadata.json | 37 ++ .../compute_instance_backups_enabled.py | 40 ++ .../__init__.py | 0 ...ance_disk_encryption_enabled.metadata.json | 37 ++ ...ompute_instance_disk_encryption_enabled.py | 42 +++ .../__init__.py | 0 ...te_instance_watchdog_enabled.metadata.json | 36 ++ .../compute_instance_watchdog_enabled.py | 40 ++ .../services/compute/compute_service.py | 99 +++++ .../linode/services/networking/__init__.py | 0 .../services/networking/networking_client.py | 6 + .../__init__.py | 0 ...firewall_assigned_to_devices.metadata.json | 38 ++ ...networking_firewall_assigned_to_devices.py | 52 +++ .../__init__.py | 0 ..._default_inbound_policy_drop.metadata.json | 38 ++ ...ng_firewall_default_inbound_policy_drop.py | 42 +++ .../__init__.py | 0 ...default_outbound_policy_drop.metadata.json | 38 ++ ...g_firewall_default_outbound_policy_drop.py | 42 +++ .../__init__.py | 0 ...all_inbound_rules_configured.metadata.json | 38 ++ ...rking_firewall_inbound_rules_configured.py | 42 +++ .../__init__.py | 0 ...ll_outbound_rules_configured.metadata.json | 38 ++ ...king_firewall_outbound_rules_configured.py | 42 +++ .../__init__.py | 0 ...king_firewall_status_enabled.metadata.json | 38 ++ .../networking_firewall_status_enabled.py | 42 +++ .../services/networking/networking_service.py | 127 +++++++ pyproject.toml | 3 + tests/lib/cli/parser_test.py | 3 +- tests/lib/outputs/finding_test.py | 81 +++++ .../mutelist/fixtures/linode_mutelist.yaml | 9 + .../lib/mutelist/linode_mutelist_test.py | 98 +++++ tests/providers/linode/linode_fixtures.py | 34 ++ .../providers/linode/linode_metadata_test.py | 46 +++ .../providers/linode/linode_provider_test.py | 146 ++++++++ .../administration_user_2fa_enabled_test.py | 97 +++++ .../linode_administration_service_test.py | 102 ++++++ .../compute_instance_backups_enabled_test.py | 109 ++++++ ...e_instance_disk_encryption_enabled_test.py | 109 ++++++ .../compute_instance_watchdog_enabled_test.py | 109 ++++++ .../compute/linode_compute_service_test.py | 143 ++++++++ .../linode_networking_service_test.py | 193 ++++++++++ ...rking_firewall_assigned_to_devices_test.py | 142 ++++++++ ...rewall_default_inbound_policy_drop_test.py | 105 ++++++ ...ewall_default_outbound_policy_drop_test.py | 105 ++++++ ..._firewall_inbound_rules_configured_test.py | 117 ++++++ ...firewall_outbound_rules_configured_test.py | 117 ++++++ ...networking_firewall_status_enabled_test.py | 105 ++++++ uv.lock | 36 ++ 93 files changed, 4074 insertions(+), 4 deletions(-) create mode 100644 docs/user-guide/providers/linode/authentication.mdx create mode 100644 docs/user-guide/providers/linode/getting-started-linode.mdx create mode 100644 prowler/compliance/linode/__init__.py create mode 100644 prowler/config/linode_mutelist_example.yaml create mode 100644 prowler/providers/linode/__init__.py create mode 100644 prowler/providers/linode/exceptions/__init__.py create mode 100644 prowler/providers/linode/exceptions/exceptions.py create mode 100644 prowler/providers/linode/lib/__init__.py create mode 100644 prowler/providers/linode/lib/arguments/__init__.py create mode 100644 prowler/providers/linode/lib/arguments/arguments.py create mode 100644 prowler/providers/linode/lib/mutelist/__init__.py create mode 100644 prowler/providers/linode/lib/mutelist/mutelist.py create mode 100644 prowler/providers/linode/lib/service/__init__.py create mode 100644 prowler/providers/linode/lib/service/service.py create mode 100644 prowler/providers/linode/linode_provider.py create mode 100644 prowler/providers/linode/models.py create mode 100644 prowler/providers/linode/services/__init__.py create mode 100644 prowler/providers/linode/services/administration/__init__.py create mode 100644 prowler/providers/linode/services/administration/administration_client.py create mode 100644 prowler/providers/linode/services/administration/administration_service.py create mode 100644 prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py create mode 100644 prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json create mode 100644 prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py create mode 100644 prowler/providers/linode/services/compute/__init__.py create mode 100644 prowler/providers/linode/services/compute/compute_client.py create mode 100644 prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py create mode 100644 prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json create mode 100644 prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py create mode 100644 prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py create mode 100644 prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json create mode 100644 prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py create mode 100644 prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py create mode 100644 prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json create mode 100644 prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py create mode 100644 prowler/providers/linode/services/compute/compute_service.py create mode 100644 prowler/providers/linode/services/networking/__init__.py create mode 100644 prowler/providers/linode/services/networking/networking_client.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json create mode 100644 prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json create mode 100644 prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json create mode 100644 prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json create mode 100644 prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json create mode 100644 prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py create mode 100644 prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json create mode 100644 prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py create mode 100644 prowler/providers/linode/services/networking/networking_service.py create mode 100644 tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml create mode 100644 tests/providers/linode/lib/mutelist/linode_mutelist_test.py create mode 100644 tests/providers/linode/linode_fixtures.py create mode 100644 tests/providers/linode/linode_metadata_test.py create mode 100644 tests/providers/linode/linode_provider_test.py create mode 100644 tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py create mode 100644 tests/providers/linode/services/administration/linode_administration_service_test.py create mode 100644 tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py create mode 100644 tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py create mode 100644 tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py create mode 100644 tests/providers/linode/services/compute/linode_compute_service_test.py create mode 100644 tests/providers/linode/services/networking/linode_networking_service_test.py create mode 100644 tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py create mode 100644 tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py create mode 100644 tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py create mode 100644 tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py create mode 100644 tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py create mode 100644 tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py diff --git a/.github/labeler.yml b/.github/labeler.yml index 1b0dfd0400..b9abb1dfd0 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -77,6 +77,11 @@ provider/okta: - any-glob-to-any-file: "prowler/providers/okta/**" - any-glob-to-any-file: "tests/providers/okta/**" +provider/linode: + - changed-files: + - any-glob-to-any-file: "prowler/providers/linode/**" + - any-glob-to-any-file: "tests/providers/linode/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 2952ebc2d5..4fc6cab0b7 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 + # Linode Provider + - name: Check if Linode files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-linode + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/linode/** + ./tests/**/linode/** + ./uv.lock + + - name: Run Linode tests + if: steps.changed-linode.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/linode --cov-report=xml:linode_coverage.xml tests/providers/linode + + - name: Upload Linode coverage to Codecov + if: steps.changed-linode.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 }}-linode + files: ./linode_coverage.xml + # External Provider (dynamic loading) - name: Check if External Provider files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/README.md b/README.md index 74e91021ca..1886377869 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI | | Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | | Okta | 1 | 1 | 0 | 1 | Official | CLI | +| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 0 | 4 | Unofficial | CLI | | Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI | | StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 0 | 3 | Unofficial | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | diff --git a/docs/docs.json b/docs/docs.json index a74aa7e91d..5b88b5d2da 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -359,6 +359,13 @@ "user-guide/providers/okta/getting-started-okta", "user-guide/providers/okta/authentication" ] + }, + { + "group": "Linode", + "pages": [ + "user-guide/providers/linode/getting-started-linode", + "user-guide/providers/linode/authentication" + ] } ] }, diff --git a/docs/user-guide/providers/linode/authentication.mdx b/docs/user-guide/providers/linode/authentication.mdx new file mode 100644 index 0000000000..feac121f40 --- /dev/null +++ b/docs/user-guide/providers/linode/authentication.mdx @@ -0,0 +1,97 @@ +--- +title: "Linode Authentication in Prowler" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Linode uses a **Personal Access Token** (PAT) for authentication. Prowler reads the token **exclusively** from the `LINODE_TOKEN` environment variable, so the secret is never exposed in shell history or process listings. There are no credential CLI flags. + +## Required Permissions + +Prowler requires read-only access to your Linode account. The following OAuth scopes are needed on the Personal Access Token: + +| Scope | Access | Description | +|-------|--------|-------------| +| `account` | `Read Only` | Required to list users and verify account identity | +| `linodes` | `Read Only` | Required to list instances and their configurations | +| `firewall` | `Read Only` | Required to list firewalls and their rules | + + +Ensure the token has all required scopes. Missing permissions will cause some checks to fail or return incomplete results. + + +--- + +## Personal Access Token + +### Step 1: Create a Personal Access Token + +1. Log into the [Linode Cloud Manager](https://cloud.linode.com). +2. Click on your username in the top-right corner, then select **API Tokens** under the "My Profile" section. +3. Click **Create a Personal Access Token**. +4. Configure the token: + - **Label:** A descriptive name (e.g., "Prowler Security Scanner") + - **Expiry:** Set an appropriate expiration (e.g., 6 months) + - **Permissions:** Set the following scopes to **Read Only**: + - Account + - Linodes + - Firewall + - All other scopes can be set to **No Access** +5. Click **Create Token**. +6. Copy the token immediately — it will not be shown again. + +### Step 2: Configure Authentication + +Set the `LINODE_TOKEN` environment variable: + +```bash +export LINODE_TOKEN="your-personal-access-token" +``` + +Then run Prowler: + +```bash +prowler linode +``` + +--- + +## Verifying Authentication + +To verify that Prowler can connect to your Linode account, run: + +```bash +prowler linode --list-checks +``` + +If authentication succeeds, you will see a list of available checks. If it fails, Prowler will display an error message indicating the credentials issue. + +--- + +## CI/CD Integration + +For automated pipelines, set the token as a secret environment variable: + +**GitHub Actions:** + +```yaml +env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + +steps: + - name: Run Prowler + run: prowler linode +``` + +**GitLab CI:** + +```yaml +variables: + LINODE_TOKEN: $LINODE_TOKEN + +prowler_scan: + script: + - prowler linode +``` diff --git a/docs/user-guide/providers/linode/getting-started-linode.mdx b/docs/user-guide/providers/linode/getting-started-linode.mdx new file mode 100644 index 0000000000..128fdf1c90 --- /dev/null +++ b/docs/user-guide/providers/linode/getting-started-linode.mdx @@ -0,0 +1,61 @@ +--- +title: 'Getting Started With Linode on Prowler' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Linode scans your Linode infrastructure for security misconfigurations, including compute settings, networking rules, user account security, and more. + + +Linode support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact). + + +## Prerequisites + +Set up authentication for Linode with the [Linode Authentication](/user-guide/providers/linode/authentication) guide before starting: + +- Create a Linode Personal Access Token with read-only permissions +- The token requires at minimum: `account:read_only`, `linodes:read_only`, and `firewall:read_only` scopes + +## Prowler CLI + +### Run Prowler for Linode + +Once authenticated with a Personal Access Token, set the `LINODE_TOKEN` environment variable and run Prowler for Linode. Prowler reads the token exclusively from the environment variable, so the secret is never exposed in shell history or process listings: + +```bash +export LINODE_TOKEN="your-personal-access-token" +prowler linode +``` + +### Run Specific Checks + +```bash +prowler linode --checks compute_instance_backups_enabled compute_instance_watchdog_enabled +``` + +### Run a Specific Service + +```bash +prowler linode --services networking +``` + +### Scan Specific Regions + +Use `--region` (alias `--filter-region` / `-f`) to limit the scan to one or more Linode regions. Region-less resources (account administration and Cloud Firewalls) are always scanned; only regional resources such as instances are filtered. When the flag is omitted, all regions are scanned. + +```bash +prowler linode --region eu-central us-east +``` + +## Available Services + +Prowler for Linode currently supports the following services: + +| Service | Description | +|---------|-------------| +| `administration` | Account administration includes users and access controls such as two-factor authentication | +| `compute` | Compute includes Linode instances and their workload configuration | +| `networking` | Networking includes Cloud Firewalls and their stateful network rules | diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bdb5f9abf0..3df8df9c2e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) - Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) - TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603) +- Support for Linode cloud provider, with compute, networking and administration services [(#11633)](https://github.com/prowler-cloud/prowler/pull/11633) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Azure provider, mapping existing Azure checks across the five DORA pillars [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) - Rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) - `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098) diff --git a/prowler/__main__.py b/prowler/__main__.py index 533a704359..d4c925f74f 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -147,6 +147,7 @@ from prowler.providers.iac.models import IACOutputOptions from prowler.providers.image.exceptions.exceptions import ImageBaseException from prowler.providers.image.models import ImageOutputOptions from prowler.providers.kubernetes.models import KubernetesOutputOptions +from prowler.providers.linode.models import LinodeOutputOptions from prowler.providers.llm.models import LLMOutputOptions from prowler.providers.m365.models import M365OutputOptions from prowler.providers.mongodbatlas.models import MongoDBAtlasOutputOptions @@ -439,6 +440,10 @@ def prowler(): output_options = ScalewayOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "linode": + output_options = LinodeOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) else: # Dynamic fallback: any external/custom provider try: diff --git a/prowler/compliance/linode/__init__.py b/prowler/compliance/linode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/config/config.py b/prowler/config/config.py index 9e2b079da6..723a9b67d3 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" + LINODE = "linode" # Compliance diff --git a/prowler/config/linode_mutelist_example.yaml b/prowler/config/linode_mutelist_example.yaml new file mode 100644 index 0000000000..1b08ab6fda --- /dev/null +++ b/prowler/config/linode_mutelist_example.yaml @@ -0,0 +1,18 @@ +### Account, Check and/or Region can be * to apply for all the cases. +### Account == +### Region == * (Linode is non-regional) +### Resources and tags are lists that can have either Regex or Keywords. +### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. +### Use an alternation Regex to match one of multiple tags with "ORed" logic. +### For each check you can except Accounts, Regions, Resources and/or Tags. +########################### MUTELIST EXAMPLE ########################### +Mutelist: + Accounts: + "example-account-uuid": + Checks: + "administration_user_2fa_enabled": + Regions: + - "*" + Resources: + - "example-user@example.com" + - "another-user@example.com" diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index 300520f589..c0c6a02e5d 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -797,6 +797,10 @@ def execute( is_finding_muted_args["org_domain"] = ( global_provider.identity.org_domain ) + elif global_provider.type == "linode": + is_finding_muted_args["account_id"] = ( + global_provider.identity.account_id + ) elif not is_builtin_provider(global_provider.type): # External/custom provider — delegate identity args is_finding_muted_args = global_provider.get_mutelist_finding_args() diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index f9155c68f7..aa16d7969b 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -1106,6 +1106,37 @@ class CheckReportCloudflare(Check_Report): return "global" +@dataclass +class CheckReportLinode(Check_Report): + """Contains the Linode Check's finding information.""" + + resource_name: str + resource_id: str + region: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str, + resource_id: str, + region: str = "global", + ) -> None: + """Initialize the Linode Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the resource. + resource_name: The name of the resource related with the finding. + resource_id: The id of the resource related with the finding. + region: The region of the resource related with the finding. + """ + super().__init__(metadata, resource) + self.resource_name = resource_name + self.resource_id = resource_id + self.region = region + + @dataclass class CheckReportM365(Check_Report): """Contains the M365 Check's finding information.""" diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 2848e9c788..b65a702fbc 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -51,6 +51,7 @@ class ProwlerArgumentParser: "okta", "scaleway", "stackit", + "linode", } all_providers = set(Provider.get_available_providers()) new_providers = sorted(all_providers - known_providers) @@ -73,10 +74,10 @@ class ProwlerArgumentParser: 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,linode,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,linode{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -96,7 +97,8 @@ Available Cloud Providers: nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider scaleway Scaleway Provider - vercel Vercel Provider{extra_providers_text} + vercel Vercel Provider + linode Linode Provider{extra_providers_text} Available components: diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 9f772d17c4..ed8bda4039 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -468,6 +468,24 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.region + elif provider.type == "linode": + output_data["auth_method"] = "api_token" + # account_uid is a required string, but the account ID may be + # unavailable when the token lacks account:read_only scope. Fall + # back to the username/email so findings are never dropped. + output_data["account_uid"] = ( + get_nested_attribute(provider, "identity.account_id") + or get_nested_attribute(provider, "identity.username") + or get_nested_attribute(provider, "identity.email") + or "linode" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.username" + ) or get_nested_attribute(provider, "identity.email") + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = check_output.region + elif provider.type == "alibabacloud": output_data["auth_method"] = get_nested_attribute( provider, "identity.identity_arn" diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 545bcbc456..18dd4b0f3d 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -1582,6 +1582,63 @@ class HTML(Output): ) return "" + @staticmethod + def get_linode_assessment_summary(provider: Provider) -> str: + """ + get_linode_assessment_summary gets the HTML assessment summary for the Linode provider + + Args: + provider (Provider): the Linode provider object + + Returns: + str: HTML assessment summary for the Linode provider + """ + try: + username = getattr(provider.identity, "username", None) or "-" + email = getattr(provider.identity, "email", None) or "-" + account_id = getattr(provider.identity, "account_id", None) or "-" + + assessment_items = f""" +
  • + Account ID: {account_id} +
  • +
  • + Username: {username} +
  • +
  • + Email: {email} +
  • """ + + credentials_items = """ +
  • + Authentication: API Token +
  • """ + + return f""" +
    +
    +
    + Linode Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Linode Credentials +
    +
      {credentials_items} +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_assessment_summary(provider: Provider) -> str: """ diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index a1f37a9dfc..40dc4635ba 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -46,6 +46,8 @@ def stdout_report(finding, color, verbose, status, fix, provider=None): details = finding.region elif finding.check_metadata.Provider == "scaleway": details = finding.region + elif finding.check_metadata.Provider == "linode": + details = finding.region else: # Dynamic fallback: any external/custom provider if provider is None: diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 43d4a547c1..77b4c2725a 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -121,6 +121,11 @@ def display_summary_table( elif provider.type == "scaleway": entity_type = "Organization" audited_entities = provider.identity.organization_id + elif provider.type == "linode": + entity_type = "Account" + audited_entities = ( + provider.identity.username or provider.identity.email or "linode" + ) else: # Dynamic fallback: any external/custom provider entity_type, audited_entities = provider.get_summary_entity() diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 77ae9180a3..9c314b3233 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -586,6 +586,16 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif arguments.provider == "linode": + # Credentials are read from the LINODE_TOKEN env var by the + # provider itself; there are no credential CLI flags to + # avoid leaking secrets. + provider_class( + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + regions=getattr(arguments, "region", None), + ) else: # Dynamic fallback: any external/custom provider. # Honor the from_cli_args type hint (-> Provider): if the diff --git a/prowler/providers/linode/__init__.py b/prowler/providers/linode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/exceptions/__init__.py b/prowler/providers/linode/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/exceptions/exceptions.py b/prowler/providers/linode/exceptions/exceptions.py new file mode 100644 index 0000000000..6c39f477db --- /dev/null +++ b/prowler/providers/linode/exceptions/exceptions.py @@ -0,0 +1,106 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 18000 to 18099 are reserved for Linode exceptions +class LinodeBaseException(ProwlerException): + """Base class for Linode errors.""" + + LINODE_ERROR_CODES = { + (18000, "LinodeCredentialsError"): { + "message": "Linode credentials not found or invalid", + "remediation": "Provide a valid Personal Access Token for Linode via the LINODE_TOKEN environment variable.", + }, + (18001, "LinodeAuthenticationError"): { + "message": "Linode authentication failed", + "remediation": "Verify the Linode Personal Access Token and ensure it has the required scopes (linodes:read_only, firewalls:read_only, account:read_only).", + }, + (18002, "LinodeSessionError"): { + "message": "Linode session setup failed", + "remediation": "Review the Linode SDK initialization parameters and credentials.", + }, + (18003, "LinodeIdentityError"): { + "message": "Unable to retrieve Linode identity or account information", + "remediation": "Ensure the Personal Access Token allows access to the Linode account and profile APIs.", + }, + (18004, "LinodeMissingPermissionError"): { + "message": "Linode token is missing a required permission scope", + "remediation": "Grant the Personal Access Token the read-only scope required for the affected service (account:read_only, linodes:read_only, firewall:read_only).", + }, + (18005, "LinodeInvalidRegionError"): { + "message": "One or more requested Linode regions are invalid", + "remediation": "Pass a valid Linode region id to --region. See https://www.linode.com/global-infrastructure/ or the API /v4/regions endpoint for the current list.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "Linode" + error_info = self.LINODE_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Linode error", + "remediation": "Check the Linode API documentation for more details.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class LinodeCredentialsError(LinodeBaseException): + """Exception for Linode credential errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18000, file=file, original_exception=original_exception, message=message + ) + + +class LinodeAuthenticationError(LinodeBaseException): + """Exception for Linode authentication errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18001, file=file, original_exception=original_exception, message=message + ) + + +class LinodeSessionError(LinodeBaseException): + """Exception for Linode session setup errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18002, file=file, original_exception=original_exception, message=message + ) + + +class LinodeIdentityError(LinodeBaseException): + """Exception for Linode identity errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18003, file=file, original_exception=original_exception, message=message + ) + + +class LinodeMissingPermissionError(LinodeBaseException): + """Exception for Linode missing permission scope errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18004, file=file, original_exception=original_exception, message=message + ) + + +class LinodeInvalidRegionError(LinodeBaseException): + """Exception for invalid Linode region filters.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18005, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/linode/lib/__init__.py b/prowler/providers/linode/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/arguments/__init__.py b/prowler/providers/linode/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/arguments/arguments.py b/prowler/providers/linode/lib/arguments/arguments.py new file mode 100644 index 0000000000..b8af36dba9 --- /dev/null +++ b/prowler/providers/linode/lib/arguments/arguments.py @@ -0,0 +1,24 @@ +def init_parser(self): + """Init the Linode provider CLI parser.""" + linode_parser = self.subparsers.add_parser( + "linode", parents=[self.common_providers_parser], help="Linode Provider" + ) + + # Authentication + # Credentials are read exclusively from the standard Linode environment + # variable (LINODE_TOKEN) to avoid leaking secrets into shell history and + # process listings. There are no credential CLI flags. + + # Regions + regions_subparser = linode_parser.add_argument_group("Regions") + regions_subparser.add_argument( + "--region", + "--filter-region", + "-f", + nargs="+", + default=None, + metavar="REGION", + help="Linode region(s) to scan (e.g. eu-central us-east). Region-less " + "resources (account, networking) are always scanned. If omitted, all " + "regions are scanned.", + ) diff --git a/prowler/providers/linode/lib/mutelist/__init__.py b/prowler/providers/linode/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/mutelist/mutelist.py b/prowler/providers/linode/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..c7da04a58a --- /dev/null +++ b/prowler/providers/linode/lib/mutelist/mutelist.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import CheckReportLinode +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class LinodeMutelist(Mutelist): + """Linode-specific mutelist helper.""" + + def is_finding_muted( + self, + finding: CheckReportLinode, + account_id: str, + ) -> bool: + """ + Check if a Linode finding is muted. + + Args: + finding: CheckReportLinode instance containing check metadata, region, resource info, and tags. + account_id: Linode account identifier. + + Returns: + True if the finding is muted, False otherwise. + """ + return self.is_muted( + account_id, + finding.check_metadata.CheckID, + finding.region or "global", + finding.resource_id or finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/linode/lib/service/__init__.py b/prowler/providers/linode/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/service/service.py b/prowler/providers/linode/lib/service/service.py new file mode 100644 index 0000000000..d1a9de65db --- /dev/null +++ b/prowler/providers/linode/lib/service/service.py @@ -0,0 +1,86 @@ +import os +from concurrent.futures import ThreadPoolExecutor, as_completed + +from prowler.lib.logger import logger +from prowler.providers.linode.exceptions.exceptions import LinodeMissingPermissionError +from prowler.providers.linode.linode_provider import LinodeProvider + +MAX_WORKERS = 10 + + +class LinodeService: + """Base class for Linode services to share provider context.""" + + def __init__(self, service: str, provider: LinodeProvider): + """ + Initialize the Linode service with provider context. + + Args: + service: The Linode service name (e.g., administration, compute, networking). + provider: LinodeProvider instance containing session, audit config, and fixer config. + """ + self.provider = provider + self.client = provider.session.client + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() if not service.islower() else service + + self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) + + def _log_fetch_error( + self, resource_label: str, required_scope: str, error: Exception + ) -> None: + """Log a resource-fetch failure, distinguishing an insufficient-scope + (HTTP 401/403) error from a generic API error. + + This never raises: a single service's missing permission must not abort + the rest of the scan. When the token lacks the required scope, the log + names the exact scope to grant via ``LinodeMissingPermissionError``. + + Args: + resource_label: Human-readable resource name (e.g. "firewalls"). + required_scope: The Linode OAuth scope needed (e.g. "firewall:read_only"). + error: The exception raised by the SDK call. + """ + service_name = getattr(self, "service", "linode") + status = getattr(error, "status", None) + if status in (401, 403) or "not authorized to use this endpoint" in str(error): + logger.error( + str( + LinodeMissingPermissionError( + file=os.path.basename(__file__), + message=( + f"{service_name} - unable to list {resource_label}: the Linode " + f"token lacks the '{required_scope}' scope; skipping these checks." + ), + original_exception=error, + ) + ) + ) + else: + logger.error( + f"{service_name} - Error fetching {resource_label}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def __threading_call__(self, call, iterator): + """Execute a function across multiple items using threading.""" + items = list(iterator) if not isinstance(iterator, list) else iterator + + futures = {self.thread_pool.submit(call, item): item for item in items} + results = [] + + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + results.append(result) + except Exception as error: + item = futures[future] + item_id = getattr(item, "id", str(item)) + logger.error( + f"{self.service} - Threading error processing {item_id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return results diff --git a/prowler/providers/linode/linode_provider.py b/prowler/providers/linode/linode_provider.py new file mode 100644 index 0000000000..ab069fab3d --- /dev/null +++ b/prowler/providers/linode/linode_provider.py @@ -0,0 +1,343 @@ +import logging +import os + +from colorama import Fore, Style +from linode_api4 import LinodeClient + +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.linode.exceptions.exceptions import ( + LinodeAuthenticationError, + LinodeCredentialsError, + LinodeIdentityError, + LinodeInvalidRegionError, + LinodeSessionError, +) +from prowler.providers.linode.lib.mutelist.mutelist import LinodeMutelist +from prowler.providers.linode.models import ( + LinodeIdentityInfo, + LinodeSession, +) + + +class LinodeProvider(Provider): + """Linode provider.""" + + _type: str = "linode" + _session: LinodeSession + _identity: LinodeIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: LinodeMutelist + _regions: set + audit_metadata: Audit_Metadata + + def __init__( + self, + config_path: str = None, + config_content: dict | None = None, + fixer_config: dict | None = None, + mutelist_path: str = None, + mutelist_content: dict = None, + token: str = None, + regions: list = None, + ): + """ + Initializes the LinodeProvider instance. + + Args: + config_path (str): Path to the configuration file. + config_content (dict): Audit configuration content. + fixer_config (dict): Fixer configuration. + mutelist_path (str): Path to the mutelist file. + mutelist_content (dict): Mutelist content. + token (str): Linode Personal Access Token (falls back to LINODE_TOKEN env var). + regions (list): Region(s) to scan regional resources in. Region-less + resources are always scanned. ``None`` scans all regions. + + Raises: + LinodeCredentialsError: If no token is provided. + LinodeSessionError: If the Linode session cannot be established. + LinodeIdentityError: If user or account identity cannot be retrieved. + """ + logger.info("Instantiating Linode provider...") + + # Mute noisy HTTP client logs + logging.getLogger("urllib3").setLevel(logging.WARNING) + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + self._session = LinodeProvider.setup_session(token=token) + + # Region filter for regional resources, validated against the live + # Linode regions list. None means scan all regions. + self._regions = LinodeProvider.validate_regions(self._session, regions) + + self._identity = LinodeProvider.setup_identity(self._session) + + self._fixer_config = fixer_config if fixer_config is not None else {} + + if mutelist_content: + self._mutelist = LinodeMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = LinodeMutelist(mutelist_path=mutelist_path) + + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self) -> LinodeMutelist: + return self._mutelist + + @property + def regions(self): + """Set of regions to scan for regional resources, or None for all.""" + return self._regions + + def validate_arguments(self) -> None: + """Linode provider has no provider-specific arguments to validate.""" + return None + + @staticmethod + def setup_session(token: str = None) -> LinodeSession: + """Initialize Linode SDK client. + + Credentials can be provided as argument or read from environment variable: + - LINODE_TOKEN (Personal Access Token) + + Args: + token: Linode Personal Access Token (optional, falls back to env var). + + Returns: + LinodeSession: The initialized Linode session. + + Raises: + LinodeCredentialsError: If no credentials are provided. + LinodeSessionError: If session setup fails. + """ + token = token or os.environ.get("LINODE_TOKEN", "") + + if not token: + raise LinodeCredentialsError( + file=os.path.basename(__file__), + message="Linode credentials not found. Set the LINODE_TOKEN environment variable.", + ) + + try: + client = LinodeClient(token) + return LinodeSession(client=client, token=token) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeSessionError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def validate_regions(session: LinodeSession, regions: list = None): + """Validate the requested regions against the live Linode regions list. + + The ``/v4/regions`` endpoint is public, so this works regardless of the + token's scope. Validating against the live list (instead of a static + file) avoids rejecting newly added Linode regions. + + Args: + session: The Linode session. + regions: The region ids requested via --region (or None). + + Returns: + The validated set of region ids, or None when no filter is given. + + Raises: + LinodeInvalidRegionError: If any requested region id is unknown. + """ + if not regions: + return None + + requested = set(regions) + try: + available = {region.id for region in session.client.regions()} + except Exception as error: + # Do not block a scan if the regions list cannot be fetched. + logger.warning( + f"Unable to validate Linode regions: {error}. " + "Proceeding with the requested regions without validation." + ) + return requested + + invalid = requested - available + if invalid: + raise LinodeInvalidRegionError( + file=os.path.basename(__file__), + message=( + f"Invalid Linode region(s): {', '.join(sorted(invalid))}. " + f"Valid regions are: {', '.join(sorted(available))}." + ), + ) + return requested + + @staticmethod + def setup_identity(session: LinodeSession) -> LinodeIdentityInfo: + """Fetch user and account metadata for Linode. + + The authenticated user's profile is retrieved first to validate the + token. Any valid token can read its own profile, so a failure here + (for example a ``401 Invalid Token``) means the credentials are invalid + and the scan is aborted instead of silently returning empty results. + + Args: + session: The Linode session. + + Returns: + LinodeIdentityInfo: The identity information. + + Raises: + LinodeAuthenticationError: If the token is invalid. + LinodeIdentityError: If identity setup fails unexpectedly. + """ + try: + client = session.client + username = None + email = None + account_id = None + + # Validate the token by reading the authenticated user's profile. + # A failure here means the credentials are invalid, so abort. + try: + profile = client.profile() + username = profile.username + email = profile.email + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + + # The account endpoint requires the account:read_only scope. A + # token without that scope is still valid, so continue without the + # account ID instead of failing the scan. + try: + account = client.account() + account_id = getattr(account, "euuid", None) + except Exception as error: + logger.warning( + f"Unable to retrieve Linode account info: {error}. Continuing without account ID." + ) + + return LinodeIdentityInfo( + username=username, + email=email, + account_id=account_id, + ) + except LinodeAuthenticationError: + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeIdentityError( + file=os.path.basename(__file__), + original_exception=error, + ) + + def print_credentials(self) -> None: + report_title = ( + f"{Style.BRIGHT}Using the Linode credentials below:{Style.RESET_ALL}" + ) + report_lines = [] + + report_lines.append( + f"Authentication: {Fore.YELLOW}Personal Access Token{Style.RESET_ALL}" + ) + + if self.identity.username: + report_lines.append( + f"Username: {Fore.YELLOW}{self.identity.username}{Style.RESET_ALL}" + ) + + if self.identity.email: + report_lines.append( + f"Email: {Fore.YELLOW}{self.identity.email}{Style.RESET_ALL}" + ) + + if self.identity.account_id: + report_lines.append( + f"Account ID: {Fore.YELLOW}{self.identity.account_id}{Style.RESET_ALL}" + ) + + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + token: str = None, + raise_on_exception: bool = True, + ) -> Connection: + """Test connection to Linode. + + Args: + token: Linode Personal Access Token. + raise_on_exception: Flag indicating whether to raise an exception if the connection fails. + + Returns: + Connection: Connection object with is_connected status. + """ + try: + session = LinodeProvider.setup_session(token=token) + # Validate by fetching profile + session.client.profile() + return Connection(is_connected=True) + except LinodeCredentialsError as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise + return Connection(is_connected=False, error=error) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise LinodeAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + return Connection(is_connected=False, error=error) diff --git a/prowler/providers/linode/models.py b/prowler/providers/linode/models.py new file mode 100644 index 0000000000..8e41e881a7 --- /dev/null +++ b/prowler/providers/linode/models.py @@ -0,0 +1,38 @@ +from typing import Any, Optional + +from pydantic import BaseModel + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class LinodeSession(BaseModel): + """Linode session information.""" + + client: Any + token: Optional[str] = None + + +class LinodeIdentityInfo(BaseModel): + """Linode identity and scoping information.""" + + username: Optional[str] = None + email: Optional[str] = None + account_id: Optional[str] = None + + +class LinodeOutputOptions(ProviderOutputOptions): + """Customize output filenames for Linode scans.""" + + def __init__(self, arguments, bulk_checks_metadata, identity: LinodeIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + account_fragment = identity.account_id or identity.username or "linode" + self.output_filename = ( + f"prowler-output-{account_fragment}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/linode/services/__init__.py b/prowler/providers/linode/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/__init__.py b/prowler/providers/linode/services/administration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/administration_client.py b/prowler/providers/linode/services/administration/administration_client.py new file mode 100644 index 0000000000..99a242e9ee --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.administration.administration_service import ( + AdministrationService, +) + +administration_client = AdministrationService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/administration/administration_service.py b/prowler/providers/linode/services/administration/administration_service.py new file mode 100644 index 0000000000..7704cf11b2 --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_service.py @@ -0,0 +1,46 @@ +from typing import List + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class User(BaseModel): + """Model for a Linode account user.""" + + username: str + email: str = "" + tfa_enabled: bool = False + restricted: bool = False + + +class AdministrationService(LinodeService): + """Service to interact with Linode Account Users.""" + + def __init__(self, provider): + super().__init__("administration", provider) + self.users: List[User] = [] + self._describe_users() + + def _describe_users(self): + """Fetch all Linode account users.""" + try: + raw_users = self.client.account.users() + for user in raw_users: + try: + self.users.append( + User( + username=getattr(user, "username", "") or "", + email=getattr(user, "email", "") or "", + tfa_enabled=getattr(user, "tfa_enabled", False) or False, + restricted=getattr(user, "restricted", False) or False, + ) + ) + except Exception as error: + logger.error( + f"account - Error processing user {getattr(user, 'username', 'unknown')}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("account users", "account:read_only", error) diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json new file mode 100644 index 0000000000..c16e26f56d --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "linode", + "CheckID": "administration_user_2fa_enabled", + "CheckTitle": "Linode account user has two-factor authentication enabled", + "CheckType": [], + "ServiceName": "administration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Linode account users** are assessed for **two-factor authentication (2FA)** enablement. 2FA adds a second verification factor (a TOTP code or security key) on top of the password, so a stolen or guessed password alone is not enough to sign in to the **Linode Cloud Manager**.", + "Risk": "Without **2FA**, a single compromised password — through phishing, credential stuffing, or brute force — grants an attacker full access to the user's Linode account. This can lead to **data exfiltration**, destruction of instances and backups, and resource abuse for **cryptomining**, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-2fa" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to My Profile > Login & Authentication\n3. Under Security Settings, configure all 3 security questions\n4. Enable Two-Factor Authentication", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `2FA` for all Linode account users. Use an authenticator app (`TOTP`) for the second factor and store backup codes securely.", + "Url": "https://hub.prowler.com/check/administration_user_2fa_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py new file mode 100644 index 0000000000..2bb2c92729 --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.administration.administration_client import ( + administration_client, +) + + +class administration_user_2fa_enabled(Check): + """Check if Linode account users have two-factor authentication enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the administration_user_2fa_enabled check. + + Iterates over all account users and checks whether two-factor + authentication is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each user. + """ + findings = [] + + for user in administration_client.users: + report = CheckReportLinode( + metadata=self.metadata(), + resource=user, + resource_name=user.username, + resource_id=user.username, + region="global", + ) + + if user.tfa_enabled: + report.status = "PASS" + report.status_extended = ( + f"User '{user.username}' has two-factor authentication enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"User '{user.username}' does not have two-factor authentication enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/__init__.py b/prowler/providers/linode/services/compute/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_client.py b/prowler/providers/linode/services/compute/compute_client.py new file mode 100644 index 0000000000..8dc03fa36d --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.compute.compute_service import ComputeService + +compute_client = ComputeService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json new file mode 100644 index 0000000000..b425e5c5b0 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_backups_enabled", + "CheckTitle": "Linode Instance has the Backup service enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for enrollment in the **Linode Backup service**, which performs automatic daily and weekly snapshots of an instance's disks. Without it enabled, there is no managed recovery point to restore from after a data-loss event.", + "Risk": "With the **Backup service** disabled, there is no automated recovery point for the instance. Accidental deletion, **ransomware**, disk corruption, or operator error can result in **permanent data loss** and extended **downtime**, directly impacting **availability** and data **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/enable-backups", + "https://techdocs.akamai.com/cloud-computing/docs/create-a-compute-instance#configure-additional-options" + ], + "Remediation": { + "Code": { + "CLI": "linode-cli linodes backups-enable ", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Select the Linode instance\n3. Navigate to the Backups tab\n4. Click 'Enable Backups'", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the `Linode Backup service` on this instance to ensure automated recovery points are available. Consider supplementing it with manual snapshots before critical changes.", + "Url": "https://hub.prowler.com/check/compute_instance_backups_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py new file mode 100644 index 0000000000..c7ae903fbf --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_backups_enabled(Check): + """Check if Linode instances have the Backup service enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_backups_enabled check. + + Iterates over all Linode instances and checks whether the Backup + service is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.backups_enabled: + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has the Backup service enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"Instance {instance.label} does not have the Backup service enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json new file mode 100644 index 0000000000..878030dc25 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_disk_encryption_enabled", + "CheckTitle": "Linode Instance has disk encryption enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for **disk encryption** status. Disk encryption protects **data at rest** on the instance's underlying disks, keeping stored data unreadable without the encryption keys even if the physical media is accessed.", + "Risk": "Without **disk encryption**, data at rest on the instance's disks is stored unprotected. This increases the risk of **data exposure** through physical access to decommissioned or stolen storage media, or via certain **hypervisor-level** vulnerabilities, compromising the **confidentiality** of sensitive data.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/local-disk-encryption", + "https://techdocs.akamai.com/cloud-computing/docs/create-a-compute-instance#enable-or-disable-disk-encryption" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Disk encryption must be enabled at instance creation time or by rebuilding the instance\n2. Create a new instance with disk_encryption set to 'enabled'\n3. Migrate data from the unencrypted instance to the new encrypted instance", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `disk encryption` for this instance. Because it cannot be toggled on existing instances, rebuild or migrate to a new instance with encryption enabled.", + "Url": "https://hub.prowler.com/check/compute_instance_disk_encryption_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py new file mode 100644 index 0000000000..abe364d0b4 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_disk_encryption_enabled(Check): + """Check if Linode instances have disk encryption enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_disk_encryption_enabled check. + + Iterates over all Linode instances and checks whether disk encryption + is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.disk_encryption == "enabled": + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has disk encryption enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Instance {instance.label} does not have disk encryption enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json new file mode 100644 index 0000000000..42e3c8f46f --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_watchdog_enabled", + "CheckTitle": "Linode Instance has Watchdog (Lassie) enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for **Watchdog (Lassie)** status. Watchdog is Linode's automatic recovery feature that monitors an instance and reboots it if it powers off unexpectedly, helping maintain availability without manual intervention.", + "Risk": "With **Watchdog (Lassie)** disabled, an instance that crashes or shuts down unexpectedly stays offline until it is manually restarted. This prolongs **downtime**, delays detection of availability incidents, and weakens the **availability** of services running on the instance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/recover-from-unexpected-shutdowns-with-lassie" + ], + "Remediation": { + "Code": { + "CLI": "linode-cli linodes update --watchdog_enabled true", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Select the Linode instance\n3. Navigate to the Settings tab\n4. Enable the Shutdown Watchdog (Lassie) toggle", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `Watchdog (Lassie)` on this instance to automatically recover from unexpected shutdowns and improve availability protection.", + "Url": "https://hub.prowler.com/check/compute_instance_watchdog_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py new file mode 100644 index 0000000000..498deb970c --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_watchdog_enabled(Check): + """Check if Linode instances have Watchdog (Lassie) enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_watchdog_enabled check. + + Iterates over all Linode instances and checks whether Watchdog + (Lassie) is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.watchdog_enabled: + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has Watchdog (Lassie) enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"Instance {instance.label} does not have Watchdog (Lassie) enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_service.py b/prowler/providers/linode/services/compute/compute_service.py new file mode 100644 index 0000000000..51d38aa834 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_service.py @@ -0,0 +1,99 @@ +from typing import List + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class Instance(BaseModel): + """Model for a Linode Instance.""" + + id: int + label: str + region: str + status: str + backups_enabled: bool = False + disk_encryption: str = "disabled" # "enabled" or "disabled" + watchdog_enabled: bool = False + tags: List[str] = [] + + +class ComputeService(LinodeService): + """Service to interact with Linode Instances.""" + + def __init__(self, provider): + super().__init__("compute", provider) + self.instances: List[Instance] = [] + self._describe_instances() + + def _describe_instances(self): + """Fetch all Linode instances with firewall and IP details.""" + # Optional --region filter. None scans all regions. Region-less services + # do not call this, so they are always scanned. + regions_filter = getattr(getattr(self, "provider", None), "regions", None) + try: + raw_instances = self.client.linode.instances() + for inst in raw_instances: + try: + region = ( + inst.region.id + if hasattr(inst.region, "id") + else str(inst.region) + ) + if regions_filter and region not in regions_filter: + continue + + # Get backup status + backups_enabled = False + try: + backups = getattr(inst, "backups", None) + if backups: + backups_enabled = getattr(backups, "enabled", False) + except Exception as error: + logger.warning( + f"instance - Unable to fetch backup status for instance " + f"{inst.id}: {error}" + ) + + # Get disk encryption status + disk_encryption = "disabled" + try: + de = getattr(inst, "disk_encryption", None) + if de: + disk_encryption = str(de) + except Exception as error: + logger.warning( + f"instance - Unable to fetch disk encryption status for " + f"instance {inst.id}: {error}" + ) + + # Get watchdog status + watchdog_enabled = False + try: + watchdog_enabled = getattr(inst, "watchdog_enabled", False) + except Exception as error: + logger.warning( + f"instance - Unable to fetch watchdog status for instance " + f"{inst.id}: {error}" + ) + + self.instances.append( + Instance( + id=inst.id, + label=inst.label or f"linode-{inst.id}", + region=region, + status=inst.status or "unknown", + backups_enabled=backups_enabled, + disk_encryption=disk_encryption, + watchdog_enabled=watchdog_enabled, + tags=inst.tags or [], + ) + ) + except Exception as error: + logger.error( + f"instance - Error processing instance {inst.id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("instances", "linodes:read_only", error) diff --git a/prowler/providers/linode/services/networking/__init__.py b/prowler/providers/linode/services/networking/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_client.py b/prowler/providers/linode/services/networking/networking_client.py new file mode 100644 index 0000000000..0f0406048b --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.networking.networking_service import ( + NetworkingService, +) + +networking_client = NetworkingService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json new file mode 100644 index 0000000000..475601e5b3 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_assigned_to_devices", + "CheckTitle": "Linode Cloud Firewall is assigned to devices", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to verify each one is attached to at least one device, such as a **Linode instance** or **NodeBalancer**. A firewall only filters traffic for the resources it is assigned to, so an unassigned firewall enforces no protection at all.", + "Risk": "An **unassigned firewall** provides no protection to running workloads, leaving their network exposure governed only by default behavior. This commonly signals a **misconfigured control** where operators assume traffic is filtered when it is not, raising the risk of **unauthorized network access** to unprotected instances.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/apply-firewall-rules-to-a-service" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click on either the Linodes or Nodebalancers tab and add those devices to the firewall", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach each firewall to applicable Linode devices, such as `Linodes` or `NodeBalancers`, to enforce intended network controls.", + "Url": "https://hub.prowler.com/check/networking_firewall_assigned_to_devices" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_status_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py new file mode 100644 index 0000000000..f245dd168e --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py @@ -0,0 +1,52 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.lib.logger import logger +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_assigned_to_devices(Check): + """Check if Linode Cloud Firewalls are assigned to at least one device.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_assigned_to_devices check. + + Iterates over all Cloud Firewalls and checks whether each one is + assigned to at least one device. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + # When the device count could not be determined (the devices fetch + # failed) skip the firewall instead of reporting a false FAIL. + if fw.attached_devices_count is None: + logger.warning( + f"firewall - Skipping firewall '{fw.label}' ({fw.id}): " + "device assignment could not be determined." + ) + continue + + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.attached_devices_count > 0: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' is assigned to {fw.attached_devices_count} device(s)." + else: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' is not assigned to any device." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json new file mode 100644 index 0000000000..16f0666f7d --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_default_inbound_policy_drop", + "CheckTitle": "Linode Cloud Firewall default inbound policy is DROP", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** apply a **default inbound policy** to ingress traffic that does not match any explicit rule. This check verifies the default is set to `DROP`, enforcing a **default-deny** posture so that only traffic intentionally permitted by a rule is allowed in.", + "Risk": "When the default inbound policy is `ACCEPT` instead of `DROP`, any traffic not explicitly denied by a rule is permitted. This **default-allow** posture can silently expose services, management ports, and unintended endpoints to the internet, enlarging the attack surface and increasing the risk of **unauthorized access**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. In the Rules tab, set the default inbound policy to DROP", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default inbound policy to `DROP` and allow only explicitly required inbound traffic.", + "Url": "https://hub.prowler.com/check/networking_firewall_default_inbound_policy_drop" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_inbound_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py new file mode 100644 index 0000000000..2f5c8cb6c3 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_default_inbound_policy_drop(Check): + """Check if Linode Cloud Firewall default inbound policy is DROP.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_default_inbound_policy_drop check. + + Iterates over all Cloud Firewalls and checks whether the default + inbound policy is DROP. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.inbound_policy == "DROP": + report.status = "PASS" + report.status_extended = ( + f"Firewall '{fw.label}' has default inbound policy set to DROP." + ) + else: + report.status = "FAIL" + report.status_extended = f"Firewall '{fw.label}' has default inbound policy set to {fw.inbound_policy}." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json new file mode 100644 index 0000000000..5de3985464 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_default_outbound_policy_drop", + "CheckTitle": "Linode Cloud Firewall default outbound policy is DROP", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** apply a **default outbound policy** to egress traffic that matches no explicit rule. This check verifies the default is set to `DROP`, enforcing **default-deny egress** so that only approved outbound connections are allowed to leave the instance.", + "Risk": "An outbound default of `ACCEPT` permits unrestricted egress from the instance. If a host is compromised, this eases **data exfiltration** and **command-and-control (C2)** communication and lets malware reach arbitrary external endpoints, harming **confidentiality** and enabling lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. In the Rules tab, set the default outbound policy to DROP", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default outbound policy to `DROP` and allow only explicit outbound destinations and services.", + "Url": "https://hub.prowler.com/check/networking_firewall_default_outbound_policy_drop" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_outbound_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py new file mode 100644 index 0000000000..011693aee9 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_default_outbound_policy_drop(Check): + """Check if Linode Cloud Firewall default outbound policy is DROP.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_default_outbound_policy_drop check. + + Iterates over all Cloud Firewalls and checks whether the default + outbound policy is DROP. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.outbound_policy == "DROP": + report.status = "PASS" + report.status_extended = ( + f"Firewall '{fw.label}' has default outbound policy set to DROP." + ) + else: + report.status = "FAIL" + report.status_extended = f"Firewall '{fw.label}' has default outbound policy set to {fw.outbound_policy}." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json new file mode 100644 index 0000000000..5e54dc6241 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_inbound_rules_configured", + "CheckTitle": "Linode Cloud Firewall inbound rules are configured", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to determine whether explicit **inbound rules** are configured. Defined ingress rules express the intended allow-list of sources, ports, and protocols instead of relying solely on the firewall's default policy.", + "Risk": "Without explicit **inbound rules**, ingress filtering depends entirely on the default policy and no intentional allow-list is documented. This makes the firewall's behavior ambiguous and harder to audit, raising the chance of **overly permissive access** or gaps in the protection operators expect.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click Add an Inbound Rule in the Rules tab", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure explicit `inbound rules` to define allowed ingress sources, ports, and protocols and enforce expected security boundaries.", + "Url": "https://hub.prowler.com/check/networking_firewall_inbound_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_default_inbound_policy_drop" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py new file mode 100644 index 0000000000..9eba9ddeb6 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_inbound_rules_configured(Check): + """Check if Linode Cloud Firewall has inbound rules configured.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_inbound_rules_configured check. + + Iterates over all Cloud Firewalls and checks whether at least one + explicit inbound rule is configured. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if len(fw.inbound_rules) == 0: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' has no inbound rules configured." + ) + else: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' has {len(fw.inbound_rules)} inbound rule(s) configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json new file mode 100644 index 0000000000..9bedf854cc --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_outbound_rules_configured", + "CheckTitle": "Linode Cloud Firewall outbound rules are configured", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to determine whether explicit **outbound rules** are configured. Defined egress rules express which destinations, ports, and protocols an instance is permitted to reach, rather than depending only on the firewall's default policy.", + "Risk": "Without explicit **outbound rules**, egress behavior depends solely on the default policy and no intended destination allow-list exists. Unconstrained or undocumented egress complicates auditing and can enable **data exfiltration** or connections to malicious endpoints if a host is compromised.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click Add an Outbound Rule in the Rules tab", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure explicit `outbound rules` to control allowed egress destinations, ports, and protocols.", + "Url": "https://hub.prowler.com/check/networking_firewall_outbound_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_default_outbound_policy_drop" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py new file mode 100644 index 0000000000..1d97dca075 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_outbound_rules_configured(Check): + """Check if Linode Cloud Firewall has outbound rules configured.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_outbound_rules_configured check. + + Iterates over all Cloud Firewalls and checks whether at least one + explicit outbound rule is configured. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if len(fw.outbound_rules) == 0: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' has no outbound rules configured." + ) + else: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' has {len(fw.outbound_rules)} outbound rule(s) configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json new file mode 100644 index 0000000000..dd1e760d4c --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_status_enabled", + "CheckTitle": "Linode Cloud Firewall status is enabled", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** can be in an `enabled` or `disabled` state. This check verifies the firewall status is `enabled`, because a firewall must be active for its inbound and outbound policies and rules to actually filter network traffic.", + "Risk": "A `disabled` firewall enforces **none** of its configured rules or default policies, leaving attached instances with no network filtering. This can unexpectedly expose management ports and services to the internet, significantly increasing the risk of **unauthorized access** and exploitation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/update-cloud-firewall-status" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Click on the Enable button from the options corresponding to the firewall whose status you would like to update", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `Linode Cloud Firewalls` so configured network filtering controls are actively enforced.", + "Url": "https://hub.prowler.com/check/networking_firewall_status_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_assigned_to_devices" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py new file mode 100644 index 0000000000..603a088bd0 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_status_enabled(Check): + """Check if Linode Cloud Firewalls are enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_status_enabled check. + + Iterates over all Cloud Firewalls and checks whether each one has + an enabled status. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.status == "enabled": + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' is enabled." + else: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' is not enabled (status: {fw.status})." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_service.py b/prowler/providers/linode/services/networking/networking_service.py new file mode 100644 index 0000000000..97f811a98a --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_service.py @@ -0,0 +1,127 @@ +from typing import List, Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class FirewallRule(BaseModel): + """Model for a single firewall rule.""" + + protocol: str = "TCP" + ports: str = "" # e.g. "22", "1-65535", "" + addresses_ipv4: List[str] = [] + addresses_ipv6: List[str] = [] + action: str = "ACCEPT" # ACCEPT or DROP + label: str = "" + + +class Firewall(BaseModel): + """Model for a Linode Cloud Firewall.""" + + id: int + label: str + status: str + inbound_rules: List[FirewallRule] = [] + outbound_rules: List[FirewallRule] = [] + inbound_policy: str + outbound_policy: str + # None means the device count could not be determined (fetch failed), as + # opposed to 0 which means the firewall genuinely has no devices attached. + attached_devices_count: Optional[int] = None + tags: List[str] = [] + + +class NetworkingService(LinodeService): + """Service to interact with Linode Cloud Firewalls.""" + + def __init__(self, provider): + super().__init__("networking", provider) + self.firewalls: List[Firewall] = [] + self._describe_firewalls() + + def _describe_firewalls(self): + """Fetch all Linode Cloud Firewalls with their rules.""" + try: + raw_firewalls = self.client.networking.firewalls() + for fw in raw_firewalls: + try: + inbound_rules = [] + outbound_rules = [] + inbound_policy = "" + outbound_policy = "" + attached_devices_count = None + + try: + attached_devices_count = len(fw.devices) + except Exception as error: + logger.warning( + f"firewall - Unable to fetch devices for firewall {fw.id}: {error}" + ) + + try: + # linode_api4 Firewall objects expose rules as a mapped object. + rules = fw.rules + inbound_policy = getattr(rules, "inbound_policy", "") + outbound_policy = getattr(rules, "outbound_policy", "") + inbound = getattr(rules, "inbound", []) + outbound = getattr(rules, "outbound", []) + + for rule in inbound: + addresses = getattr(rule, "addresses", None) + inbound_rules.append( + FirewallRule( + protocol=( + getattr(rule, "protocol", None) or "TCP" + ).upper(), + ports=getattr(rule, "ports", "") or "", + addresses_ipv4=getattr(addresses, "ipv4", []) or [], + addresses_ipv6=getattr(addresses, "ipv6", []) or [], + action=( + getattr(rule, "action", None) or "ACCEPT" + ).upper(), + label=getattr(rule, "label", "") or "", + ) + ) + for rule in outbound: + addresses = getattr(rule, "addresses", None) + outbound_rules.append( + FirewallRule( + protocol=( + getattr(rule, "protocol", None) or "TCP" + ).upper(), + ports=getattr(rule, "ports", "") or "", + addresses_ipv4=getattr(addresses, "ipv4", []) or [], + addresses_ipv6=getattr(addresses, "ipv6", []) or [], + action=( + getattr(rule, "action", None) or "ACCEPT" + ).upper(), + label=getattr(rule, "label", "") or "", + ) + ) + except Exception as error: + logger.warning( + f"firewall - Unable to fetch rules for firewall {fw.id}: {error}" + ) + + self.firewalls.append( + Firewall( + id=fw.id, + label=fw.label or f"firewall-{fw.id}", + status=fw.status or "unknown", + inbound_rules=inbound_rules, + outbound_rules=outbound_rules, + inbound_policy=inbound_policy, + outbound_policy=outbound_policy, + attached_devices_count=attached_devices_count, + tags=fw.tags or [], + ) + ) + except Exception as error: + logger.error( + f"firewall - Error processing firewall {fw.id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("firewalls", "firewall:read_only", error) diff --git a/pyproject.toml b/pyproject.toml index f3cec52678..8ad1f6888d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ dependencies = [ "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", "kubernetes==32.0.1", + "linode-api4==5.45.0", "markdown==3.10.2", "microsoft-kiota-abstractions==1.9.9", "numpy==2.2.6", @@ -218,6 +219,7 @@ constraint-dependencies = [ "coverage==7.6.12", "darabonba-core==1.0.5", "decorator==5.2.1", + "deprecated==1.3.1", "dill==0.4.1", "distro==1.9.0", "dnspython==2.8.0", @@ -300,6 +302,7 @@ constraint-dependencies = [ "platformdirs==4.9.6", "plotly==6.7.0", "pluggy==1.6.0", + "polling==0.3.2", "prek==0.3.9", "propcache==0.5.2", "proto-plus==1.28.0", diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index cd602a3a61..94b6ad2b4d 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # 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,linode,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -39,6 +39,7 @@ def mock_get_available_providers(): "cloudflare", "openstack", "stackit", + "linode", ] diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index 0f1cb14e25..f843cb8f00 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -761,6 +761,87 @@ class TestFinding: assert finding_output.metadata.Severity == Severity.high assert finding_output.metadata.ResourceType == "mock_resource_type" + def _build_linode_check_output(self): + check_output = MagicMock() + check_output.resource_id = "12345" + check_output.resource_name = "test-instance" + check_output.resource_details = "" + check_output.resource_tags = {} + check_output.region = "us-east" + check_output.status = Status.PASS + check_output.status_extended = "Instance is compliant" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="linode") + check_output.resource = {} + check_output.compliance = {} + return check_output + + def test_generate_output_linode(self): + """Test Linode output generation when the account ID is available.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username="admin", + email="admin@example.com", + account_id="E1AF1B6C-1111-2222-3333-444455556666", + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert finding_output.provider == "linode" + assert finding_output.auth_method == "api_token" + assert finding_output.account_uid == "E1AF1B6C-1111-2222-3333-444455556666" + assert finding_output.account_name == "admin" + + def test_generate_output_linode_without_account_id_falls_back_to_username(self): + """account_uid is required; when account_id is None it must fall back to + the username so findings are never silently dropped.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username="admin", + email="admin@example.com", + account_id=None, + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + # Must not raise a ValidationError and must use the username fallback + assert finding_output.account_uid == "admin" + + def test_generate_output_linode_without_account_id_or_username(self): + """When neither account_id nor username/email is available, account_uid + falls back to the literal provider name.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username=None, + email=None, + account_id=None, + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert finding_output.account_uid == "linode" + def test_generate_output_iac_remote(self): # Mock provider provider = MagicMock() diff --git a/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml b/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml new file mode 100644 index 0000000000..60a3edef84 --- /dev/null +++ b/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml @@ -0,0 +1,9 @@ +Mutelist: + Accounts: + "E1AF1B6C-1111-2222-3333-444455556666": + Checks: + "administration_user_2fa_enabled": + Regions: + - "*" + Resources: + - "admin" diff --git a/tests/providers/linode/lib/mutelist/linode_mutelist_test.py b/tests/providers/linode/lib/mutelist/linode_mutelist_test.py new file mode 100644 index 0000000000..f122e5bcae --- /dev/null +++ b/tests/providers/linode/lib/mutelist/linode_mutelist_test.py @@ -0,0 +1,98 @@ +from unittest.mock import MagicMock + +import yaml + +from prowler.providers.linode.lib.mutelist.mutelist import LinodeMutelist + +MUTELIST_FIXTURE_PATH = ( + "tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml" +) + + +class Test_linode_mutelist: + def test_get_mutelist_file_from_local_file(self): + mutelist = LinodeMutelist(mutelist_path=MUTELIST_FIXTURE_PATH) + + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + assert mutelist.mutelist == mutelist_fixture + assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH + + def test_get_mutelist_file_from_local_file_non_existent(self): + mutelist_path = "tests/providers/linode/lib/mutelist/fixtures/not_present" + mutelist = LinodeMutelist(mutelist_path=mutelist_path) + + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path == mutelist_path + + def test_validate_mutelist_not_valid_key(self): + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"] + del mutelist_fixture["Accounts"] + + mutelist = LinodeMutelist(mutelist_content=mutelist_fixture) + + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path is None + + def test_is_finding_muted(self): + mutelist_content = { + "Accounts": { + "E1AF1B6C-1111-2222-3333-444455556666": { + "Checks": { + "administration_user_2fa_enabled": { + "Regions": ["*"], + "Resources": ["admin"], + } + } + } + } + } + + mutelist = LinodeMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "administration_user_2fa_enabled" + finding.status = "FAIL" + finding.region = "global" + finding.resource_id = "admin" + finding.resource_name = "admin" + finding.resource_tags = [] + + assert mutelist.is_finding_muted( + finding, "E1AF1B6C-1111-2222-3333-444455556666" + ) + + def test_is_finding_not_muted(self): + mutelist_content = { + "Accounts": { + "E1AF1B6C-1111-2222-3333-444455556666": { + "Checks": { + "administration_user_2fa_enabled": { + "Regions": ["*"], + "Resources": ["other-user"], + } + } + } + } + } + + mutelist = LinodeMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "administration_user_2fa_enabled" + finding.status = "FAIL" + finding.region = "global" + finding.resource_id = "admin" + finding.resource_name = "admin" + finding.resource_tags = [] + + assert not mutelist.is_finding_muted( + finding, "E1AF1B6C-1111-2222-3333-444455556666" + ) diff --git a/tests/providers/linode/linode_fixtures.py b/tests/providers/linode/linode_fixtures.py new file mode 100644 index 0000000000..5786ffe881 --- /dev/null +++ b/tests/providers/linode/linode_fixtures.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock + +from prowler.providers.linode.models import ( + LinodeIdentityInfo, + LinodeSession, +) + +# Linode Identity +USERNAME = "admin" +EMAIL = "admin@example.com" +ACCOUNT_ID = "E1AF1B6C-1111-2222-3333-444455556666" + +# Linode Credentials +TOKEN = "fake-linode-token-for-testing" + + +def set_mocked_linode_provider( + username: str = USERNAME, + email: str = EMAIL, + account_id: str = ACCOUNT_ID, +): + """Return a mocked LinodeProvider with identity and session set.""" + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username=username, + email=email, + account_id=account_id, + ) + provider.session = LinodeSession( + client=MagicMock(), + token=TOKEN, + ) + return provider diff --git a/tests/providers/linode/linode_metadata_test.py b/tests/providers/linode/linode_metadata_test.py new file mode 100644 index 0000000000..9409c468af --- /dev/null +++ b/tests/providers/linode/linode_metadata_test.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from prowler.lib.check.models import CheckMetadata + +EXPECTED_SERVICE_NAMES = { + "administration": "administration", + "compute": "compute", + "networking": "networking", +} + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_check_metadata_is_valid(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + assert metadata.Provider == "linode" + assert metadata.CheckID == metadata_file.stem.replace(".metadata", "") + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_checks_metadata_use_canonical_hub_urls(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + url = metadata.Remediation.Recommendation.Url + assert not url.startswith( + "https://hub.prowler.com/checks/linode/" + ), f"{metadata_file}: non-canonical hub URL {url}" + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_check_metadata_uses_product_area_service_names(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + service_folder = metadata_file.relative_to( + Path("prowler/providers/linode/services") + ).parts[0] + + assert metadata.ServiceName == EXPECTED_SERVICE_NAMES[service_folder] diff --git a/tests/providers/linode/linode_provider_test.py b/tests/providers/linode/linode_provider_test.py new file mode 100644 index 0000000000..67df6ccc20 --- /dev/null +++ b/tests/providers/linode/linode_provider_test.py @@ -0,0 +1,146 @@ +import os +from unittest import mock + +import pytest + +from prowler.providers.linode.exceptions.exceptions import ( + LinodeAuthenticationError, + LinodeCredentialsError, + LinodeInvalidRegionError, +) +from prowler.providers.linode.linode_provider import LinodeProvider +from prowler.providers.linode.models import LinodeIdentityInfo, LinodeSession +from tests.providers.linode.linode_fixtures import ( + ACCOUNT_ID, + EMAIL, + TOKEN, + USERNAME, +) + + +class TestLinodeProvider_setup_session: + def test_missing_token_raises_credentials_error(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": ""}, clear=False): + os.environ.pop("LINODE_TOKEN", None) + with pytest.raises(LinodeCredentialsError): + LinodeProvider.setup_session() + + def test_returns_session_with_token(self): + session = LinodeProvider.setup_session(token=TOKEN) + assert isinstance(session, LinodeSession) + assert session.token == TOKEN + assert session.client is not None + + def test_reads_token_from_env(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": TOKEN}, clear=False): + session = LinodeProvider.setup_session() + assert session.token == TOKEN + + +class TestLinodeProvider_setup_identity: + def _build_session(self): + client = mock.MagicMock() + return LinodeSession(client=client, token=TOKEN) + + def test_resolves_identity_from_profile_and_account(self): + session = self._build_session() + profile = mock.MagicMock() + profile.username = USERNAME + profile.email = EMAIL + session.client.profile.return_value = profile + + account = mock.MagicMock() + account.euuid = ACCOUNT_ID + session.client.account.return_value = account + + identity = LinodeProvider.setup_identity(session) + + assert isinstance(identity, LinodeIdentityInfo) + assert identity.username == USERNAME + assert identity.email == EMAIL + assert identity.account_id == ACCOUNT_ID + + def test_invalid_token_raises_authentication_error(self): + # An invalid token fails the profile call (any valid token can read its + # own profile), so the scan must abort instead of returning empty data. + session = self._build_session() + session.client.profile.side_effect = Exception("[401] Invalid Token") + + with pytest.raises(LinodeAuthenticationError): + LinodeProvider.setup_identity(session) + + def test_identity_with_account_failure_still_returns(self): + session = self._build_session() + profile = mock.MagicMock() + profile.username = USERNAME + profile.email = EMAIL + session.client.profile.return_value = profile + session.client.account.side_effect = Exception("forbidden") + + identity = LinodeProvider.setup_identity(session) + + assert identity.username == USERNAME + assert identity.email == EMAIL + assert identity.account_id is None + + +class TestLinodeProvider_test_connection: + def test_successful_connection(self): + with mock.patch( + "prowler.providers.linode.linode_provider.LinodeProvider.setup_session" + ) as mock_session: + session = mock.MagicMock() + session.client.profile.return_value = mock.MagicMock() + mock_session.return_value = session + + conn = LinodeProvider.test_connection(token=TOKEN, raise_on_exception=False) + + assert conn.is_connected is True + + def test_missing_credentials(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": ""}, clear=False): + os.environ.pop("LINODE_TOKEN", None) + conn = LinodeProvider.test_connection(token=None, raise_on_exception=False) + assert conn.is_connected is False + + def test_connection_failure_raises_when_requested(self): + with mock.patch( + "prowler.providers.linode.linode_provider.LinodeProvider.setup_session" + ) as mock_session: + mock_session.side_effect = LinodeCredentialsError( + file="test", message="No token" + ) + with pytest.raises(LinodeCredentialsError): + LinodeProvider.test_connection(token=None, raise_on_exception=True) + + +class TestLinodeProvider_validate_regions: + def _session_with_regions(self, region_ids): + client = mock.MagicMock() + client.regions.return_value = [mock.MagicMock(id=rid) for rid in region_ids] + return LinodeSession(client=client, token=TOKEN) + + def test_no_regions_returns_none(self): + session = self._session_with_regions(["eu-central", "us-east"]) + assert LinodeProvider.validate_regions(session, None) is None + + def test_valid_regions_returns_set(self): + session = self._session_with_regions(["eu-central", "us-east", "ap-south"]) + result = LinodeProvider.validate_regions(session, ["eu-central", "us-east"]) + assert result == {"eu-central", "us-east"} + + def test_invalid_region_raises(self): + session = self._session_with_regions(["eu-central", "us-east"]) + with pytest.raises(LinodeInvalidRegionError): + LinodeProvider.validate_regions(session, ["eu-central", "nonexistent"]) + + def test_regions_api_failure_does_not_block(self): + # If the public regions list cannot be fetched, the scan proceeds with + # the requested regions instead of failing. + client = mock.MagicMock() + client.regions.side_effect = Exception("regions API error") + session = LinodeSession(client=client, token=TOKEN) + + result = LinodeProvider.validate_regions(session, ["eu-central"]) + + assert result == {"eu-central"} diff --git a/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py b/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py new file mode 100644 index 0000000000..a4e77c662a --- /dev/null +++ b/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py @@ -0,0 +1,97 @@ +from unittest import mock + +from prowler.providers.linode.services.administration.administration_service import ( + User, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_administration_user_2fa_enabled: + def test_no_users(self): + administration_client = mock.MagicMock() + administration_client.users = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_user_with_2fa(self): + administration_client = mock.MagicMock() + administration_client.users = [ + User( + username="admin", + email="admin@example.com", + tfa_enabled=True, + restricted=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "admin" + assert result[0].resource_id == "admin" + + def test_user_without_2fa(self): + administration_client = mock.MagicMock() + administration_client.users = [ + User( + username="dev-user", + email="dev@example.com", + tfa_enabled=False, + restricted=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "dev-user" + assert result[0].resource_id == "dev-user" diff --git a/tests/providers/linode/services/administration/linode_administration_service_test.py b/tests/providers/linode/services/administration/linode_administration_service_test.py new file mode 100644 index 0000000000..b8393c073a --- /dev/null +++ b/tests/providers/linode/services/administration/linode_administration_service_test.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.administration.administration_service import ( + AdministrationService, +) + + +def _mock_user(username="admin", email="admin@example.com", tfa=True, restricted=False): + user = MagicMock() + user.username = username + user.email = email + user.tfa_enabled = tfa + user.restricted = restricted + return user + + +def _build_service(account_users_return=None, account_users_side_effect=None): + """Build an AdministrationService with an isolated mock client.""" + service = object.__new__(AdministrationService) + service.users = [] + + # Build isolated mock hierarchy for client.account.users() + # Must explicitly create the users callable as a fresh MagicMock + # because check tests contaminate MagicMock class with users=[...] + users_callable = MagicMock() + if account_users_side_effect: + users_callable.side_effect = account_users_side_effect + else: + users_callable.return_value = account_users_return or [] + + account_mock = MagicMock() + account_mock.users = users_callable + + client_mock = MagicMock() + client_mock.account = account_mock + service.client = client_mock + return service + + +class TestLinodeAdministrationService: + def test_describe_users_parses_correctly(self): + mock_users = [ + _mock_user("admin", "admin@example.com", True, False), + _mock_user("reader", "reader@example.com", False, True), + ] + + service = _build_service(account_users_return=mock_users) + service._describe_users() + + assert len(service.users) == 2 + assert service.users[0].username == "admin" + assert service.users[0].tfa_enabled is True + assert service.users[1].username == "reader" + assert service.users[1].restricted is True + + def test_describe_users_handles_empty_list(self): + service = _build_service(account_users_return=[]) + service._describe_users() + + assert len(service.users) == 0 + + def test_describe_users_handles_api_error(self): + service = _build_service(account_users_side_effect=Exception("API error")) + service._describe_users() + + assert len(service.users) == 0 + + def test_describe_users_handles_null_fields(self): + """A user with null tfa_enabled/email must still be included with safe + defaults instead of being dropped by a ValidationError.""" + user = MagicMock() + user.username = "partial" + user.email = None + user.tfa_enabled = None + user.restricted = None + + service = _build_service(account_users_return=[user]) + service._describe_users() + + assert len(service.users) == 1 + assert service.users[0].username == "partial" + assert service.users[0].email == "" + assert service.users[0].tfa_enabled is False + assert service.users[0].restricted is False + + def test_describe_users_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(account_users_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_users() + + assert len(service.users) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "account:read_only" in logged diff --git a/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py new file mode 100644 index 0000000000..93e19fa6b1 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_backups_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_backups_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + backups_enabled=True, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has the Backup service enabled." + ) + + def test_instance_backups_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + backups_enabled=False, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have the Backup service enabled." + ) diff --git a/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py new file mode 100644 index 0000000000..d5023d2f94 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_disk_encryption_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_disk_encryption_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + disk_encryption="enabled", + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has disk encryption enabled." + ) + + def test_instance_disk_encryption_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + disk_encryption="disabled", + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have disk encryption enabled." + ) diff --git a/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py new file mode 100644 index 0000000000..db5ddaacd0 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_watchdog_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_watchdog_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + watchdog_enabled=True, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has Watchdog (Lassie) enabled." + ) + + def test_instance_watchdog_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + watchdog_enabled=False, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have Watchdog (Lassie) enabled." + ) diff --git a/tests/providers/linode/services/compute/linode_compute_service_test.py b/tests/providers/linode/services/compute/linode_compute_service_test.py new file mode 100644 index 0000000000..09b1a54e84 --- /dev/null +++ b/tests/providers/linode/services/compute/linode_compute_service_test.py @@ -0,0 +1,143 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.compute.compute_service import ( + ComputeService, +) + + +def _mock_instance( + id=1, + label="my-instance", + region="us-east", + status="running", + backups_enabled=True, + disk_encryption="enabled", + watchdog_enabled=True, + tags=None, +): + inst = MagicMock() + inst.id = id + inst.label = label + region_mock = MagicMock() + region_mock.id = region + inst.region = region_mock + inst.status = status + backups = MagicMock() + backups.enabled = backups_enabled + inst.backups = backups + inst.disk_encryption = disk_encryption + inst.watchdog_enabled = watchdog_enabled + inst.tags = tags or [] + return inst + + +def _build_service(linode_instances_return=None, linode_instances_side_effect=None): + """Build a ComputeService with an isolated mock client.""" + service = object.__new__(ComputeService) + service.instances = [] + + # Build isolated mock hierarchy for client.linode.instances() + # Must explicitly create the instances callable as a fresh MagicMock + # because check tests contaminate MagicMock class with instances=[...] + instances_callable = MagicMock() + if linode_instances_side_effect: + instances_callable.side_effect = linode_instances_side_effect + else: + instances_callable.return_value = linode_instances_return or [] + + linode_mock = MagicMock() + linode_mock.instances = instances_callable + + client_mock = MagicMock() + client_mock.linode = linode_mock + service.client = client_mock + return service + + +class TestLinodeComputeService: + def test_describe_instances_parses_correctly(self): + mock_instances = [ + _mock_instance(id=1, label="web-1", region="us-east"), + _mock_instance(id=2, label="db-1", region="eu-west", backups_enabled=False), + ] + + service = _build_service(linode_instances_return=mock_instances) + service._describe_instances() + + assert len(service.instances) == 2 + assert service.instances[0].label == "web-1" + assert service.instances[0].region == "us-east" + assert service.instances[0].backups_enabled is True + assert service.instances[1].label == "db-1" + assert service.instances[1].backups_enabled is False + + def test_describe_instances_handles_empty_list(self): + service = _build_service(linode_instances_return=[]) + service._describe_instances() + + assert len(service.instances) == 0 + + def test_describe_instances_handles_api_error(self): + service = _build_service(linode_instances_side_effect=Exception("API error")) + service._describe_instances() + + assert len(service.instances) == 0 + + def test_describe_instances_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(linode_instances_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_instances() + + assert len(service.instances) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "linodes:read_only" in logged + + def test_describe_instances_disk_encryption(self): + mock_instances = [ + _mock_instance(id=1, disk_encryption="enabled"), + _mock_instance(id=2, disk_encryption="disabled"), + ] + + service = _build_service(linode_instances_return=mock_instances) + service._describe_instances() + + assert service.instances[0].disk_encryption == "enabled" + assert service.instances[1].disk_encryption == "disabled" + + def test_describe_instances_region_filter_keeps_only_matching(self): + mock_instances = [ + _mock_instance(id=1, label="eu", region="eu-central"), + _mock_instance(id=2, label="us", region="us-east"), + _mock_instance(id=3, label="eu-2", region="eu-central"), + ] + service = _build_service(linode_instances_return=mock_instances) + service.provider = MagicMock() + service.provider.regions = {"eu-central"} + + service._describe_instances() + + assert len(service.instances) == 2 + assert {i.label for i in service.instances} == {"eu", "eu-2"} + assert all(i.region == "eu-central" for i in service.instances) + + def test_describe_instances_no_region_filter_keeps_all(self): + mock_instances = [ + _mock_instance(id=1, region="eu-central"), + _mock_instance(id=2, region="us-east"), + ] + service = _build_service(linode_instances_return=mock_instances) + service.provider = MagicMock() + service.provider.regions = None + + service._describe_instances() + + assert len(service.instances) == 2 diff --git a/tests/providers/linode/services/networking/linode_networking_service_test.py b/tests/providers/linode/services/networking/linode_networking_service_test.py new file mode 100644 index 0000000000..cd4f43f423 --- /dev/null +++ b/tests/providers/linode/services/networking/linode_networking_service_test.py @@ -0,0 +1,193 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.networking.networking_service import ( + NetworkingService, +) + + +def _mock_rule( + protocol="TCP", ports="22", ipv4=None, ipv6=None, action="ACCEPT", label="" +): + rule = MagicMock() + rule.protocol = protocol + rule.ports = ports + rule.action = action + rule.label = label + addresses = MagicMock() + addresses.ipv4 = ipv4 or [] + addresses.ipv6 = ipv6 or [] + rule.addresses = addresses + return rule + + +def _mock_firewall( + id=1, label="my-fw", status="enabled", inbound=None, outbound=None, tags=None +): + fw = MagicMock() + fw.id = id + fw.label = label + fw.status = status + fw.tags = tags or [] + rules = MagicMock() + rules.inbound = inbound or [] + rules.outbound = outbound or [] + rules.inbound_policy = "DROP" + rules.outbound_policy = "DROP" + fw.rules = rules + return fw + + +def _build_service( + networking_firewalls_return=None, networking_firewalls_side_effect=None +): + """Build a NetworkingService instance with a properly isolated mock client.""" + service = object.__new__(NetworkingService) + service.firewalls = [] + + firewalls_callable = MagicMock() + if networking_firewalls_side_effect: + firewalls_callable.side_effect = networking_firewalls_side_effect + else: + firewalls_callable.return_value = networking_firewalls_return or [] + + networking_mock = MagicMock() + networking_mock.firewalls = firewalls_callable + + client_mock = MagicMock() + client_mock.networking = networking_mock + service.client = client_mock + return service + + +class TestLinodeNetworkingService: + def test_describe_firewalls_parses_correctly(self): + inbound_rules = [ + _mock_rule("TCP", "22", ipv4=["192.168.1.0/24"]), + _mock_rule("TCP", "443", ipv4=["0.0.0.0/0"]), + ] + mock_fws = [ + _mock_firewall(id=1, label="prod-fw", inbound=inbound_rules), + ] + + service = _build_service(networking_firewalls_return=mock_fws) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].label == "prod-fw" + assert len(service.firewalls[0].inbound_rules) == 2 + assert service.firewalls[0].inbound_rules[0].ports == "22" + assert service.firewalls[0].inbound_rules[0].addresses_ipv4 == [ + "192.168.1.0/24" + ] + assert service.firewalls[0].inbound_rules[1].addresses_ipv4 == ["0.0.0.0/0"] + + def test_describe_firewalls_handles_empty_list(self): + service = _build_service(networking_firewalls_return=[]) + service._describe_firewalls() + + assert len(service.firewalls) == 0 + + def test_describe_firewalls_handles_api_error(self): + service = _build_service( + networking_firewalls_side_effect=Exception("API error") + ) + service._describe_firewalls() + + assert len(service.firewalls) == 0 + + def test_describe_firewalls_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(networking_firewalls_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_firewalls() + + assert len(service.firewalls) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "firewall:read_only" in logged + + def test_describe_firewalls_device_fetch_error_yields_none_count(self): + """A devices fetch failure must leave attached_devices_count as None + (undetermined) rather than 0, to avoid a false 'not assigned' FAIL.""" + + class _NoDevicesFw: + id = 5 + label = "no-devices-fw" + status = "enabled" + tags = [] + + @property + def devices(self): + raise Exception("devices API error") + + @property + def rules(self): + r = MagicMock() + r.inbound = [] + r.outbound = [] + r.inbound_policy = "DROP" + r.outbound_policy = "DROP" + return r + + service = _build_service(networking_firewalls_return=[_NoDevicesFw()]) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].attached_devices_count is None + + def test_describe_firewalls_handles_null_rule_fields(self): + """Rule fields returned as explicit null must fall back to defaults + instead of raising a ValidationError that drops the whole firewall.""" + rule = MagicMock() + rule.protocol = None + rule.ports = None + rule.action = None + rule.label = None + addresses = MagicMock() + addresses.ipv4 = None + addresses.ipv6 = None + rule.addresses = addresses + + mock_fws = [_mock_firewall(id=6, label="null-rule-fw", inbound=[rule])] + + service = _build_service(networking_firewalls_return=mock_fws) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + parsed = service.firewalls[0].inbound_rules[0] + assert parsed.protocol == "TCP" + assert parsed.action == "ACCEPT" + assert parsed.ports == "" + assert parsed.addresses_ipv4 == [] + assert parsed.addresses_ipv6 == [] + assert parsed.label == "" + + def test_describe_firewalls_handles_rules_fetch_error(self): + """Firewall is still added even if rules fail to load.""" + + class _BrokenFw: + id = 1 + label = "broken-fw" + status = "enabled" + tags = [] + devices = [] + + @property + def rules(self): + raise Exception("rules API error") + + fw = _BrokenFw() + + service = _build_service(networking_firewalls_return=[fw]) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].label == "broken-fw" + assert len(service.firewalls[0].inbound_rules) == 0 diff --git a/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py b/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py new file mode 100644 index 0000000000..72d8a9f454 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py @@ -0,0 +1,142 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_assigned_to_devices: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 0 + + def test_firewall_assigned(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="assigned-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=2, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "assigned-fw" + + def test_firewall_not_assigned(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="unassigned-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=0, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "unassigned-fw" + + def test_firewall_device_count_undetermined_is_skipped(self): + # attached_devices_count is None when the devices fetch failed; the + # firewall must be skipped rather than reported as a false FAIL. + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=102, + label="undetermined-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=None, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py b/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py new file mode 100644 index 0000000000..1543a45656 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_default_inbound_policy_drop: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 0 + + def test_inbound_policy_drop(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="drop-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "drop-fw" + + def test_inbound_policy_accept(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="accept-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="ACCEPT", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "accept-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py b/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py new file mode 100644 index 0000000000..5a586d710c --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_default_outbound_policy_drop: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 0 + + def test_outbound_policy_drop(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="drop-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "drop-fw" + + def test_outbound_policy_accept(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="accept-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="ACCEPT", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "accept-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py b/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py new file mode 100644 index 0000000000..9b8272c2f2 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py @@ -0,0 +1,117 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import ( + Firewall, + FirewallRule, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_inbound_rules_configured: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 0 + + def test_inbound_rules_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="empty-inbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "100" + assert result[0].resource_name == "empty-inbound-fw" + + def test_inbound_rules_not_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="non-empty-inbound-fw", + status="enabled", + inbound_rules=[ + FirewallRule( + protocol="TCP", + ports="443", + addresses_ipv4=["0.0.0.0/0"], + addresses_ipv6=[], + action="ACCEPT", + label="allow-https", + ) + ], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "101" + assert result[0].resource_name == "non-empty-inbound-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py b/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py new file mode 100644 index 0000000000..658569c940 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py @@ -0,0 +1,117 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import ( + Firewall, + FirewallRule, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_outbound_rules_configured: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 0 + + def test_outbound_rules_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="empty-outbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "100" + assert result[0].resource_name == "empty-outbound-fw" + + def test_outbound_rules_not_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="non-empty-outbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[ + FirewallRule( + protocol="TCP", + ports="443", + addresses_ipv4=["0.0.0.0/0"], + addresses_ipv6=[], + action="ACCEPT", + label="allow-https-egress", + ) + ], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "101" + assert result[0].resource_name == "non-empty-outbound-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py b/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py new file mode 100644 index 0000000000..632d94ca2e --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_status_enabled: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_firewall_enabled(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="enabled-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "enabled-fw" + + def test_firewall_disabled(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="disabled-fw", + status="disabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "disabled-fw" diff --git a/uv.lock b/uv.lock index b221255e33..4d0d6c0a70 100644 --- a/uv.lock +++ b/uv.lock @@ -70,6 +70,7 @@ constraints = [ { name = "coverage", specifier = "==7.6.12" }, { name = "darabonba-core", specifier = "==1.0.5" }, { name = "decorator", specifier = "==5.2.1" }, + { name = "deprecated", specifier = "==1.3.1" }, { name = "dill", specifier = "==0.4.1" }, { name = "distro", specifier = "==1.9.0" }, { name = "dnspython", specifier = "==2.8.0" }, @@ -152,6 +153,7 @@ constraints = [ { name = "platformdirs", specifier = "==4.9.6" }, { name = "plotly", specifier = "==6.7.0" }, { name = "pluggy", specifier = "==1.6.0" }, + { name = "polling", specifier = "==0.3.2" }, { name = "prek", specifier = "==0.3.9" }, { name = "propcache", specifier = "==0.5.2" }, { name = "proto-plus", specifier = "==1.28.0" }, @@ -1781,6 +1783,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "detect-secrets" version = "1.5.0" @@ -2516,6 +2530,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" }, ] +[[package]] +name = "linode-api4" +version = "5.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "polling" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/b5/fce03d9b81008dcc0fe4961ce10e140ac3ae5ab17f2cdd659763e4964c0d/linode_api4-5.45.0.tar.gz", hash = "sha256:af8a0a5638345ad467447112dcf5d58ec47e7dd192b89ce0c8537a1e5c435d04", size = 283375, upload-time = "2026-06-11T18:05:13.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/38/19e3c8f7b7a9dbeea2aa5af61f70162bff5131b3d39acbe73e8d0dd12972/linode_api4-5.45.0-py3-none-any.whl", hash = "sha256:3cc2650b13d8d3bc7735fa8e92a639669618f320471dc8e519db778c6020eacd", size = 158336, upload-time = "2026-06-11T18:05:11.799Z" }, +] + [[package]] name = "lz4" version = "4.4.5" @@ -3372,6 +3400,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polling" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/c5/4249317962180d97ec7a60fe38aa91f86216533bd478a427a5468945c5c9/polling-0.3.2.tar.gz", hash = "sha256:3afd62320c99b725c70f379964bf548b302fc7f04d4604e6c315d9012309cc9a", size = 5189, upload-time = "2021-05-22T19:48:41.466Z" } + [[package]] name = "prek" version = "0.3.9" @@ -3578,6 +3612,7 @@ dependencies = [ { name = "h2" }, { name = "jsonschema" }, { name = "kubernetes" }, + { name = "linode-api4" }, { name = "markdown" }, { name = "microsoft-kiota-abstractions" }, { name = "msgraph-sdk" }, @@ -3686,6 +3721,7 @@ requires-dist = [ { name = "h2", specifier = "==4.3.0" }, { name = "jsonschema", specifier = "==4.23.0" }, { name = "kubernetes", specifier = "==32.0.1" }, + { name = "linode-api4", specifier = "==5.45.0" }, { name = "markdown", specifier = "==3.10.2" }, { name = "microsoft-kiota-abstractions", specifier = "==1.9.9" }, { name = "msgraph-sdk", specifier = "==1.55.0" },