diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 5d7fd45567..226056afea 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.20.0] (Prowler UNRELEASED) +### 🚀 Added + +- OpenStack provider support [(#10003)](https://github.com/prowler-cloud/prowler/pull/10003) + ### 🔄 Changed - Attack Paths: Queries definition now has short description and attribution [(#9983)](https://github.com/prowler-cloud/prowler/pull/9983) diff --git a/api/src/backend/api/migrations/0076_openstack_provider.py b/api/src/backend/api/migrations/0076_openstack_provider.py new file mode 100644 index 0000000000..9cc80707ea --- /dev/null +++ b/api/src/backend/api/migrations/0076_openstack_provider.py @@ -0,0 +1,39 @@ +# Generated by Django migration for OpenStack provider support + +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0075_cloudflare_provider"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ("cloudflare", "Cloudflare"), + ("openstack", "OpenStack"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'openstack';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 4bda51761c..eb09bba683 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -288,6 +288,7 @@ class Provider(RowLevelSecurityProtectedModel): ORACLECLOUD = "oraclecloud", _("Oracle Cloud Infrastructure") ALIBABACLOUD = "alibabacloud", _("Alibaba Cloud") CLOUDFLARE = "cloudflare", _("Cloudflare") + OPENSTACK = "openstack", _("OpenStack") @staticmethod def validate_aws_uid(value): @@ -410,6 +411,15 @@ class Provider(RowLevelSecurityProtectedModel): pointer="/data/attributes/uid", ) + @staticmethod + def validate_openstack_uid(value): + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$", value): + raise ModelValidationError( + detail="OpenStack provider ID must be a valid project ID (UUID or project name).", + code="openstack-uid", + pointer="/data/attributes/uid", + ) + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index ec5f9e6ec5..88d67c01c1 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -367,6 +367,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -380,6 +381,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -398,6 +400,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -413,6 +416,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -1348,6 +1352,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -1361,6 +1366,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -1379,6 +1385,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -1394,6 +1401,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -1938,6 +1946,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -1951,6 +1960,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -1969,6 +1979,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -1984,6 +1995,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -2436,6 +2448,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -2449,6 +2462,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -2467,6 +2481,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -2482,6 +2497,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -2932,6 +2948,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -2945,6 +2962,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -2963,6 +2981,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -2978,6 +2997,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -3416,6 +3436,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -3429,6 +3450,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -3447,6 +3469,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -3462,6 +3485,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -5241,6 +5265,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -5254,6 +5279,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -5272,6 +5298,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -5287,6 +5314,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - name: filter[search] @@ -5404,6 +5432,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -5417,6 +5446,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -5435,6 +5465,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -5450,6 +5481,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - name: filter[search] @@ -5556,6 +5588,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -5569,6 +5602,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -5586,6 +5620,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -5601,6 +5636,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - name: filter[search] @@ -5739,6 +5775,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -5752,6 +5789,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -5770,6 +5808,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -5785,6 +5824,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -5936,6 +5976,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -5949,6 +5990,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -5967,6 +6009,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -5982,6 +6025,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -6127,6 +6171,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -6140,6 +6185,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -6157,6 +6203,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -6172,6 +6219,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - name: filter[search] @@ -6359,6 +6407,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -6372,6 +6421,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -6390,6 +6440,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -6405,6 +6456,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -6521,6 +6573,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -6534,6 +6587,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -6552,6 +6606,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -6567,6 +6622,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -6707,6 +6763,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -6720,6 +6777,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -6738,6 +6796,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -6753,6 +6812,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -7534,6 +7594,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -7547,6 +7608,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider__in] schema: @@ -7565,6 +7627,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -7580,6 +7643,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -7598,6 +7662,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -7611,6 +7676,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -7629,6 +7695,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -7644,6 +7711,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - name: filter[search] @@ -8285,6 +8353,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -8298,6 +8367,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -8316,6 +8386,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -8331,6 +8402,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -8787,6 +8859,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -8800,6 +8873,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -8818,6 +8892,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -8833,6 +8908,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -9102,6 +9178,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -9115,6 +9192,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -9133,6 +9211,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -9148,6 +9227,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -9423,6 +9503,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -9436,6 +9517,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -9454,6 +9536,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -9469,6 +9552,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -10278,6 +10362,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- * `aws` - AWS @@ -10291,6 +10376,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: @@ -10309,6 +10395,7 @@ paths: - kubernetes - m365 - mongodbatlas + - openstack - oraclecloud description: |- Multiple values may be separated by commas. @@ -10324,6 +10411,7 @@ paths: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -17348,6 +17436,50 @@ components: required: - api_key - api_email + - type: object + title: OpenStack clouds.yaml Credentials + properties: + clouds_yaml_content: + type: string + description: The full content of a clouds.yaml configuration + file. + clouds_yaml_cloud: + type: string + description: The name of the cloud to use from the clouds.yaml + file. + required: + - clouds_yaml_content + - clouds_yaml_cloud + - type: object + title: OpenStack Explicit Credentials + properties: + auth_url: + type: string + description: OpenStack Keystone authentication URL (e.g., + https://openstack.example.com:5000/v3). + username: + type: string + description: OpenStack username for authentication. + password: + type: string + description: OpenStack password for authentication. + region_name: + type: string + description: OpenStack region name (e.g., RegionOne). + identity_api_version: + type: string + description: Keystone API version (default: 3). + user_domain_name: + type: string + description: User domain name (default: Default). + project_domain_name: + type: string + description: Project domain name (default: Default). + required: + - auth_url + - username + - password + - region_name writeOnly: true required: - secret @@ -18347,6 +18479,7 @@ components: - oraclecloud - alibabacloud - cloudflare + - openstack type: string description: |- * `aws` - AWS @@ -18360,6 +18493,7 @@ components: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack x-spec-enum-id: 2d8d323e9cc0044b uid: type: string @@ -18477,6 +18611,7 @@ components: - oraclecloud - alibabacloud - cloudflare + - openstack type: string x-spec-enum-id: 2d8d323e9cc0044b description: |- @@ -18493,6 +18628,7 @@ components: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack uid: type: string title: Unique identifier for the provider, set by the provider @@ -18541,6 +18677,7 @@ components: - oraclecloud - alibabacloud - cloudflare + - openstack type: string x-spec-enum-id: 2d8d323e9cc0044b description: |- @@ -18557,6 +18694,7 @@ components: * `oraclecloud` - Oracle Cloud Infrastructure * `alibabacloud` - Alibaba Cloud * `cloudflare` - Cloudflare + * `openstack` - OpenStack uid: type: string minLength: 3 @@ -19378,6 +19516,48 @@ components: required: - api_key - api_email + - type: object + title: OpenStack clouds.yaml Credentials + properties: + clouds_yaml_content: + type: string + description: The full content of a clouds.yaml configuration file. + clouds_yaml_cloud: + type: string + description: The name of the cloud to use from the clouds.yaml + file. + required: + - clouds_yaml_content + - clouds_yaml_cloud + - type: object + title: OpenStack Explicit Credentials + properties: + auth_url: + type: string + description: OpenStack Keystone authentication URL (e.g., https://openstack.example.com:5000/v3). + username: + type: string + description: OpenStack username for authentication. + password: + type: string + description: OpenStack password for authentication. + region_name: + type: string + description: OpenStack region name (e.g., RegionOne). + identity_api_version: + type: string + description: Keystone API version (default: 3). + user_domain_name: + type: string + description: User domain name (default: Default). + project_domain_name: + type: string + description: Project domain name (default: Default). + required: + - auth_url + - username + - password + - region_name writeOnly: true required: - secret_type @@ -19764,6 +19944,50 @@ components: required: - api_key - api_email + - type: object + title: OpenStack clouds.yaml Credentials + properties: + clouds_yaml_content: + type: string + description: The full content of a clouds.yaml configuration + file. + clouds_yaml_cloud: + type: string + description: The name of the cloud to use from the clouds.yaml + file. + required: + - clouds_yaml_content + - clouds_yaml_cloud + - type: object + title: OpenStack Explicit Credentials + properties: + auth_url: + type: string + description: OpenStack Keystone authentication URL (e.g., + https://openstack.example.com:5000/v3). + username: + type: string + description: OpenStack username for authentication. + password: + type: string + description: OpenStack password for authentication. + region_name: + type: string + description: OpenStack region name (e.g., RegionOne). + identity_api_version: + type: string + description: Keystone API version (default: 3). + user_domain_name: + type: string + description: User domain name (default: Default). + project_domain_name: + type: string + description: Project domain name (default: Default). + required: + - auth_url + - username + - password + - region_name writeOnly: true required: - secret_type @@ -20162,6 +20386,48 @@ components: required: - api_key - api_email + - type: object + title: OpenStack clouds.yaml Credentials + properties: + clouds_yaml_content: + type: string + description: The full content of a clouds.yaml configuration file. + clouds_yaml_cloud: + type: string + description: The name of the cloud to use from the clouds.yaml + file. + required: + - clouds_yaml_content + - clouds_yaml_cloud + - type: object + title: OpenStack Explicit Credentials + properties: + auth_url: + type: string + description: OpenStack Keystone authentication URL (e.g., https://openstack.example.com:5000/v3). + username: + type: string + description: OpenStack username for authentication. + password: + type: string + description: OpenStack password for authentication. + region_name: + type: string + description: OpenStack region name (e.g., RegionOne). + identity_api_version: + type: string + description: Keystone API version (default: 3). + user_domain_name: + type: string + description: User domain name (default: Default). + project_domain_name: + type: string + description: Project domain name (default: Default). + required: + - auth_url + - username + - password + - region_name writeOnly: true required: - secret diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 56f98a5401..236cbf87a2 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -27,6 +27,7 @@ from prowler.providers.iac.iac_provider import IacProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider from prowler.providers.m365.m365_provider import M365Provider from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider +from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider @@ -120,6 +121,7 @@ class TestReturnProwlerProvider: (Provider.ProviderChoices.IAC.value, IacProvider), (Provider.ProviderChoices.ALIBABACLOUD.value, AlibabacloudProvider), (Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider), + (Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider), ], ) def test_return_prowler_provider(self, provider_type, expected_provider): @@ -227,6 +229,10 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.CLOUDFLARE.value, {"filter_accounts": ["provider_uid"]}, ), + ( + Provider.ProviderChoices.OPENSTACK.value, + {}, + ), ], ) def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs): diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index c9f2746cfe..02aefa18ff 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -1179,6 +1179,11 @@ class TestProviderViewSet: "uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "alias": "Cloudflare Account", }, + { + "provider": "openstack", + "uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "alias": "OpenStack Project", + }, ] ), ) @@ -1598,6 +1603,26 @@ class TestProviderViewSet: "cloudflare-uid", "uid", ), + # OpenStack UID validation - starts with special character + ( + { + "provider": "openstack", + "uid": "-invalid-project", + "alias": "test", + }, + "openstack-uid", + "uid", + ), + # OpenStack UID validation - too short (below min_length) + ( + { + "provider": "openstack", + "uid": "ab", + "alias": "test", + }, + "min_length", + "uid", + ), ] ), ) @@ -1771,21 +1796,21 @@ class TestProviderViewSet: ( "uid.icontains", "1", - 9, + 10, ), ("alias", "aws_testing_1", 1), ("alias.icontains", "aws", 2), - ("inserted_at", TODAY, 10), + ("inserted_at", TODAY, 11), ( "inserted_at.gte", "2024-01-01", - 10, + 11, ), ("inserted_at.lte", "2024-01-01", 0), ( "updated_at.gte", "2024-01-01", - 10, + 11, ), ("updated_at.lte", "2024-01-01", 0), ] @@ -2392,6 +2417,15 @@ class TestProviderSecretViewSet: "api_email": "user@example.com", }, ), + # OpenStack with clouds.yaml content + ( + Provider.ProviderChoices.OPENSTACK.value, + ProviderSecret.TypeChoices.STATIC, + { + "clouds_yaml_content": "clouds:\n mycloud:\n auth:\n auth_url: https://openstack.example.com:5000/v3\n", + "clouds_yaml_cloud": "mycloud", + }, + ), ], ) def test_provider_secrets_create_valid( diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 343537efa9..355c3b6e03 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: from prowler.providers.mongodbatlas.mongodbatlas_provider import ( MongodbatlasProvider, ) + from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider @@ -78,12 +79,14 @@ def return_prowler_provider( AlibabacloudProvider | AwsProvider | AzureProvider + | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider + | OpenstackProvider | OraclecloudProvider ): """Return the Prowler provider class based on the given provider type. @@ -92,7 +95,7 @@ def return_prowler_provider( provider (Provider): The provider object containing the provider type and associated secrets. Returns: - AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class. + AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: The corresponding provider class. Raises: ValueError: If the provider type specified in `provider.provider` is not supported. @@ -152,6 +155,10 @@ def return_prowler_provider( ) prowler_provider = CloudflareProvider + case Provider.ProviderChoices.OPENSTACK.value: + from prowler.providers.openstack.openstack_provider import OpenstackProvider + + prowler_provider = OpenstackProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -208,6 +215,12 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "filter_accounts": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: + # No extra kwargs needed: clouds_yaml_content and clouds_yaml_cloud from the + # secret are sufficient. Validating project_id (provider.uid) against the + # clouds.yaml is not feasible because not all auth methods include it and the + # Keystone API is unavailable on public clouds. + pass if mutelist_processor: mutelist_content = mutelist_processor.configuration.get("Mutelist", {}) @@ -232,6 +245,7 @@ def initialize_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OpenstackProvider | OraclecloudProvider ): """Initialize a Prowler provider instance based on the given provider type. @@ -241,7 +255,7 @@ def initialize_prowler_provider( mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration. Returns: - AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class + AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: An instance of the corresponding provider class initialized with the provider's secrets. """ prowler_provider = return_prowler_provider(provider) @@ -276,6 +290,13 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: if "access_token" in prowler_provider_kwargs: iac_test_kwargs["access_token"] = prowler_provider_kwargs["access_token"] return prowler_provider.test_connection(**iac_test_kwargs) + elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: + openstack_kwargs = { + "clouds_yaml_content": prowler_provider_kwargs["clouds_yaml_content"], + "clouds_yaml_cloud": prowler_provider_kwargs["clouds_yaml_cloud"], + "raise_on_exception": False, + } + return prowler_provider.test_connection(**openstack_kwargs) else: return prowler_provider.test_connection( **prowler_provider_kwargs, diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 7b34b06d2b..83ea942793 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -373,6 +373,21 @@ from rest_framework_json_api import serializers }, "required": ["api_key", "api_email"], }, + { + "type": "object", + "title": "OpenStack clouds.yaml Credentials", + "properties": { + "clouds_yaml_content": { + "type": "string", + "description": "The full content of a clouds.yaml configuration file.", + }, + "clouds_yaml_cloud": { + "type": "string", + "description": "The name of the cloud to use from the clouds.yaml file.", + }, + }, + "required": ["clouds_yaml_content", "clouds_yaml_cloud"], + }, ] } ) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 6c42d83aa5..d97a952bc3 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1525,6 +1525,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): "or both 'api_key' and 'api_email'." } ) + elif provider_type == Provider.ProviderChoices.OPENSTACK.value: + serializer = OpenStackCloudsYamlProviderSecret(data=secret) else: raise serializers.ValidationError( {"provider": f"Provider type not supported {provider_type}"} @@ -1691,6 +1693,14 @@ class CloudflareApiKeyProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class OpenStackCloudsYamlProviderSecret(serializers.Serializer): + clouds_yaml_content = serializers.CharField() + clouds_yaml_cloud = serializers.CharField() + + class Meta: + resource_name = "provider-secrets" + + class AlibabaCloudProviderSecret(serializers.Serializer): access_key_id = serializers.CharField() access_key_secret = serializers.CharField() diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py index 5289f067fa..75779f5a68 100644 --- a/api/src/backend/config/django/testing.py +++ b/api/src/backend/config/django/testing.py @@ -18,6 +18,10 @@ DATABASES = { DATABASE_ROUTERS = [] TESTING = True +# Override page size for testing to a value only slightly above the current fixture count. +# We explicitly set PAGE_SIZE to 15 (round number just above fixture) to avoid masking pagination bugs, while not setting it excessively high. +# If you add more providers to the fixture, please review that the total value is below the current one and update this value if needed. +REST_FRAMEWORK["PAGE_SIZE"] = 15 # noqa: F405 SECRETS_ENCRYPTION_KEY = "ZMiYVo7m4Fbe2eXXPyrwxdJss2WSalXSv3xHBcJkPl0=" # DRF Simple API Key settings diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index 00617017bb..e2c22e5112 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -537,6 +537,12 @@ def providers_fixture(tenants_fixture): alias="cloudflare_testing", tenant_id=tenant.id, ) + provider11 = Provider.objects.create( + provider="openstack", + uid="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + alias="openstack_testing", + tenant_id=tenant.id, + ) return ( provider1, @@ -549,6 +555,7 @@ def providers_fixture(tenants_fixture): provider8, provider9, provider10, + provider11, ) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a0128d64fe..773776ce20 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- OpenStack provider `clouds_yaml_content` parameter for API integration [(#10003)](https://github.com/prowler-cloud/prowler/pull/10003) - `defender_safe_attachments_policy_enabled` check for M365 provider [(#9833)](https://github.com/prowler-cloud/prowler/pull/9833) - `defender_safelinks_policy_enabled` check for M365 provider [(#9832)](https://github.com/prowler-cloud/prowler/pull/9832) - AI Skills: Added a skill for creating new Attack Paths queries in openCypher, compatible with Neo4j and Neptune [(#9975)](https://github.com/prowler-cloud/prowler/pull/9975) diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 0ffe15e825..339220df30 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -297,6 +297,9 @@ class Provider(ABC): elif "openstack" in provider_class_name.lower(): provider_class( clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None), + clouds_yaml_content=getattr( + arguments, "clouds_yaml_content", None + ), clouds_yaml_cloud=getattr(arguments, "clouds_yaml_cloud", None), auth_url=getattr(arguments, "os_auth_url", None), identity_api_version=getattr( diff --git a/prowler/providers/openstack/openstack_provider.py b/prowler/providers/openstack/openstack_provider.py index fd8e2e005a..d892dc1abb 100644 --- a/prowler/providers/openstack/openstack_provider.py +++ b/prowler/providers/openstack/openstack_provider.py @@ -6,6 +6,7 @@ from colorama import Fore, Style from openstack import config, connect from openstack import exceptions as openstack_exceptions from openstack.connection import Connection as OpenStackConnection +from yaml import YAMLError, safe_load from prowler.config.config import ( default_config_file_path, @@ -49,6 +50,7 @@ class OpenstackProvider(Provider): def __init__( self, clouds_yaml_file: Optional[str] = None, + clouds_yaml_content: Optional[str] = None, clouds_yaml_cloud: Optional[str] = None, auth_url: Optional[str] = None, identity_api_version: Optional[str] = None, @@ -68,6 +70,7 @@ class OpenstackProvider(Provider): self._session = self.setup_session( clouds_yaml_file=clouds_yaml_file, + clouds_yaml_content=clouds_yaml_content, clouds_yaml_cloud=clouds_yaml_cloud, auth_url=auth_url, identity_api_version=identity_api_version, @@ -132,6 +135,7 @@ class OpenstackProvider(Provider): @staticmethod def setup_session( clouds_yaml_file: Optional[str] = None, + clouds_yaml_content: Optional[str] = None, clouds_yaml_cloud: Optional[str] = None, auth_url: Optional[str] = None, identity_api_version: Optional[str] = None, @@ -145,10 +149,16 @@ class OpenstackProvider(Provider): """Collect authentication information from clouds.yaml, explicit parameters, or environment variables. Authentication priority: - 1. clouds.yaml file (if clouds_yaml_file or clouds_yaml_cloud provided) + 1. clouds.yaml content/file (if clouds_yaml_content, clouds_yaml_file, or clouds_yaml_cloud provided) 2. Explicit parameters + environment variable fallback """ # Priority 1: clouds.yaml authentication + if clouds_yaml_content: + logger.info("Using clouds.yaml content string for authentication") + return OpenstackProvider._setup_session_from_clouds_yaml_content( + clouds_yaml_content=clouds_yaml_content, + clouds_yaml_cloud=clouds_yaml_cloud, + ) if clouds_yaml_file or clouds_yaml_cloud: logger.info("Using clouds.yaml configuration for authentication") return OpenstackProvider._setup_session_from_clouds_yaml( @@ -203,6 +213,73 @@ class OpenstackProvider(Provider): project_domain_name=resolved_project_domain, ) + @staticmethod + def _setup_session_from_clouds_yaml_content( + clouds_yaml_content: str, + clouds_yaml_cloud: Optional[str] = None, + ) -> OpenStackSession: + """Setup session from clouds.yaml content provided as a string. + + Parses the YAML content directly instead of writing to a temporary file, + following the same pattern as KubernetesProvider.setup_session(). + + Args: + clouds_yaml_content: The full YAML content of a clouds.yaml file. + clouds_yaml_cloud: Cloud name to use from the clouds.yaml content. + + Returns: + OpenStackSession configured from the provided clouds.yaml content. + + Raises: + OpenStackInvalidConfigError: If the YAML is malformed or missing required fields. + OpenStackCloudNotFoundError: If the specified cloud is not found in the content. + """ + if not clouds_yaml_cloud: + raise OpenStackInvalidConfigError( + message="Cloud name (--clouds-yaml-cloud) is required when using clouds.yaml content", + ) + + try: + parsed = safe_load(clouds_yaml_content) + except YAMLError as error: + raise OpenStackInvalidConfigError( + original_exception=error, + message=f"Failed to parse clouds.yaml content: {error}", + ) + + if not isinstance(parsed, dict) or "clouds" not in parsed: + raise OpenStackInvalidConfigError( + message="Invalid clouds.yaml content: missing 'clouds' key", + ) + + cloud_config = parsed["clouds"].get(clouds_yaml_cloud) + if not cloud_config: + raise OpenStackCloudNotFoundError( + message=f"Cloud '{clouds_yaml_cloud}' not found in clouds.yaml content", + ) + + auth_dict = cloud_config.get("auth", {}) + + required_fields = ["auth_url", "username", "password"] + missing_fields = [ + field for field in required_fields if not auth_dict.get(field) + ] + if missing_fields: + raise OpenStackInvalidConfigError( + message=f"Missing required fields in clouds.yaml for cloud '{clouds_yaml_cloud}': {', '.join(missing_fields)}", + ) + + return OpenStackSession( + auth_url=auth_dict.get("auth_url"), + identity_api_version=str(cloud_config.get("identity_api_version", "3")), + username=auth_dict.get("username"), + password=auth_dict.get("password"), + project_id=auth_dict.get("project_id") or auth_dict.get("project_name"), + region_name=cloud_config.get("region_name"), + user_domain_name=auth_dict.get("user_domain_name", "Default"), + project_domain_name=auth_dict.get("project_domain_name", "Default"), + ) + @staticmethod def _setup_session_from_clouds_yaml( clouds_yaml_file: Optional[str] = None, @@ -394,6 +471,7 @@ class OpenstackProvider(Provider): @staticmethod def test_connection( clouds_yaml_file: Optional[str] = None, + clouds_yaml_content: Optional[str] = None, clouds_yaml_cloud: Optional[str] = None, auth_url: Optional[str] = None, identity_api_version: Optional[str] = None, @@ -412,6 +490,7 @@ class OpenstackProvider(Provider): Args: clouds_yaml_file: Path to clouds.yaml configuration file + clouds_yaml_content: The full content of a clouds.yaml file as a string clouds_yaml_cloud: Cloud name from clouds.yaml to use auth_url: OpenStack Keystone authentication URL identity_api_version: Keystone API version (default: "3") @@ -456,6 +535,7 @@ class OpenstackProvider(Provider): # Setup session with provided credentials session = OpenstackProvider.setup_session( clouds_yaml_file=clouds_yaml_file, + clouds_yaml_content=clouds_yaml_content, clouds_yaml_cloud=clouds_yaml_cloud, auth_url=auth_url, identity_api_version=identity_api_version,