diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml
index 6307d5d6df..d0ef83c772 100644
--- a/.github/workflows/sdk-tests.yml
+++ b/.github/workflows/sdk-tests.yml
@@ -541,6 +541,54 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml
+ # Scaleway Provider
+ - name: Check if Scaleway files changed
+ if: steps.check-changes.outputs.any_changed == 'true'
+ id: changed-scaleway
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
+ with:
+ files: |
+ ./prowler/**/scaleway/**
+ ./tests/**/scaleway/**
+ ./uv.lock
+
+ - name: Run Scaleway tests
+ if: steps.changed-scaleway.outputs.any_changed == 'true'
+ run: uv run pytest -n auto --cov=./prowler/providers/scaleway --cov-report=xml:scaleway_coverage.xml tests/providers/scaleway
+
+ - name: Upload Scaleway coverage to Codecov
+ if: steps.changed-scaleway.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 }}-scaleway
+ files: ./scaleway_coverage.xml
+
+ # StackIT Provider
+ - name: Check if StackIT files changed
+ if: steps.check-changes.outputs.any_changed == 'true'
+ id: changed-stackit
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
+ with:
+ files: |
+ ./prowler/**/stackit/**
+ ./tests/**/stackit/**
+ ./uv.lock
+
+ - name: Run StackIT tests
+ if: steps.changed-stackit.outputs.any_changed == 'true'
+ run: uv run pytest -n auto --cov=./prowler/providers/stackit --cov-report=xml:stackit_coverage.xml tests/providers/stackit
+
+ - name: Upload StackIT coverage to Codecov
+ if: steps.changed-stackit.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 }}-stackit
+ files: ./stackit_coverage.xml
+
# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
diff --git a/README.md b/README.md
index 41ad716c2b..eff78169b0 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI |
| Okta | 1 | 1 | 0 | 1 | Official | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI |
+| StackIT [Contact us](https://prowler.com/contact) | 4 | 1 | 0 | 1 | Unofficial | CLI |
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
> [!Note]
diff --git a/docs/developer-guide/stackit-details.mdx b/docs/developer-guide/stackit-details.mdx
new file mode 100644
index 0000000000..9d4ea5d458
--- /dev/null
+++ b/docs/developer-guide/stackit-details.mdx
@@ -0,0 +1,730 @@
+---
+title: 'StackIT Provider'
+---
+
+This page details the [StackIT Cloud](https://www.stackit.de/) provider implementation in Prowler.
+
+By default, Prowler audits a single StackIT project per scan. To configure it, provide the project ID and either a service account key file path or inline service account key JSON.
+
+## StackIT Provider Classes Architecture
+
+The StackIT provider implementation follows the general [Provider structure](/developer-guide/provider). This section focuses on the StackIT-specific implementation, highlighting how the generic provider concepts are realized for StackIT in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](/developer-guide/provider).
+
+### `StackitProvider` (Main Class)
+
+- **Location:** [`prowler/providers/stackit/stackit_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/stackit_provider.py)
+- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)).
+- **Purpose:** Central orchestrator for StackIT-specific logic, API authentication, credential validation, and configuration.
+- **Key StackIT Responsibilities:**
+ - Initializes StackIT SDK authentication via a service account key file or inline service account key JSON. The SDK mints and refreshes access tokens internally.
+ - Validates the service account credentials and project ID (UUID format validation).
+ - Loads and manages configuration, mutelist, and fixer settings.
+ - Provides properties and methods for downstream StackIT service classes to access credentials, identity, and configuration data.
+
+### Data Models
+
+- **Location:** [`prowler/providers/stackit/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/models.py)
+- **Purpose:** Define structured data for StackIT identity and output configuration.
+- **Key StackIT Models:**
+ - `StackITIdentityInfo`: Holds StackIT identity metadata, including project ID and project name (fetched automatically from Resource Manager API).
+ - `StackITOutputOptions`: Customizes default output filenames so StackIT reports include the audited project ID.
+ - IaaS resource models such as `SecurityGroup` and `SecurityGroupRule` are defined in the IaaS service module.
+
+### StackIT Services
+
+- **Location:** [`prowler/providers/stackit/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services)
+- **Purpose:** Implement StackIT service clients and resource collection logic following the generic [service pattern](/developer-guide/services#service-base-class).
+- **Current Implementation:** The `IaaSService` collects security groups, rules, and network interface usage across supported StackIT regions.
+
+### Exception Handling
+
+- **Location:** [`prowler/providers/stackit/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/exceptions/exceptions.py)
+- **Purpose:** Custom exception classes for StackIT-specific error handling, such as credential validation, API connection, and configuration errors.
+- **Key Exception Classes:**
+ - `StackITBaseException`: Base exception for all StackIT provider errors.
+ - `StackITCredentialsError`: Raised when credentials are invalid or missing.
+ - `StackITInvalidProjectIdError`: Raised when project ID is invalid or not in UUID format.
+ - `StackITAPIError`: Raised when StackIT API calls fail.
+
+## Authentication
+
+### Service Account Creation and Key Generation
+
+StackIT uses service account keys for API authentication. Service account keys are RSA key-pair based and provide secure, short-lived access tokens.
+
+### Creating a Service Account Key
+
+#### Method 1: Via StackIT Portal
+
+1. **Navigate to Service Accounts**
+ - Go to the [StackIT Portal](https://portal.stackit.cloud/)
+ - Select your project
+ - Click on **Service Accounts** in the left sidebar
+
+2. **Create or Select Service Account**
+ - If you don't have a service account, click **Create Service Account**
+ - Provide a name and description
+ - Assign necessary permissions:
+ - For IaaS security checks: `iaas.viewer` or `project.owner`
+ - For comprehensive audits: `project.owner`
+
+3. **Generate Service Account Key**
+ - Select your service account
+ - Navigate to **Service Account Keys**
+ - Click **Create key**
+ - Choose one of the following options:
+ - **STACKIT-generated key pair** (Recommended): Let STACKIT automatically generate an RSA key-pair
+ - **User-provided key pair**: Upload your own RSA 2048 public key
+
+4. **Download and Save the Key**
+ - Download the generated service account key file (JSON format)
+ - **Important**: Save the key securely - it contains your private key and will only be available once
+ - Store the key file in a secure location (e.g., `~/.stackit/sa_key.json`)
+
+#### Method 2: Via StackIT CLI
+
+```bash
+# Install STACKIT CLI (if not already installed)
+# Follow instructions at: https://github.com/stackitcloud/stackit-cli
+
+# Create service account key (STACKIT-generated)
+stackit service-account key create --email my-service-account@example.com
+
+# Or create with your own RSA 2048 public key
+# First, generate your RSA key pair:
+openssl genrsa -out private-key.pem 2048
+openssl rsa -in private-key.pem -pubout -out public-key.pem
+
+# Then create the key with your public key:
+stackit service-account key create \
+ --email my-service-account@example.com \
+ --public-key "$(cat public-key.pem)"
+```
+
+### Finding Your Project ID
+
+Your StackIT project ID is a UUID that can be found:
+
+1. In the StackIT Portal URL when viewing your project: `https://portal.stackit.cloud/projects/{PROJECT_ID}/...`
+2. In the project settings page
+3. Using the StackIT CLI: `stackit project list`
+
+### Passing the Service Account Key to Prowler
+
+Prowler accepts the service account credentials in two equivalent forms; both go through the same StackIT SDK flow and refresh access tokens internally.
+
+#### Option 1: Key File Path (key persisted on disk)
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+
+prowler stackit
+```
+
+Or as CLI flags:
+
+```bash
+prowler stackit \
+ --stackit-service-account-key-path ~/.stackit/sa-key.json \
+ --stackit-project-id 12345678-1234-1234-1234-123456789abc
+```
+
+#### Option 2: Inline Key Content (CI/CD, secret managers)
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+
+prowler stackit
+```
+
+Prefer the environment variable over the matching `--stackit-service-account-key` CLI flag; passing the secret on the command line leaks it through process listings and shell history.
+
+### Credential Lookup Order
+
+Prowler resolves credentials in this order:
+
+1. **Command-line arguments**:
+ - `--stackit-service-account-key`
+ - `--stackit-service-account-key-path`
+ - `--stackit-project-id`
+2. **Environment variables**:
+ - `STACKIT_SERVICE_ACCOUNT_KEY`
+ - `STACKIT_SERVICE_ACCOUNT_KEY_PATH`
+ - `STACKIT_PROJECT_ID`
+
+When both the inline key and the key file path are set, the inline content takes precedence.
+
+## Configuration
+
+### Command-Line Arguments
+
+StackIT-specific command-line arguments:
+
+| Argument | Description | Required | Default |
+|----------|-------------|----------|---------|
+| `--stackit-service-account-key-path` | Path to a StackIT service account key JSON file | Yes* | `$STACKIT_SERVICE_ACCOUNT_KEY_PATH` |
+| `--stackit-service-account-key` | Inline JSON content of a StackIT service account key (preferred env var: `STACKIT_SERVICE_ACCOUNT_KEY`) | Yes* | `$STACKIT_SERVICE_ACCOUNT_KEY` |
+| `--stackit-project-id` | StackIT project ID (UUID format) | Yes* | `$STACKIT_PROJECT_ID` |
+| `--stackit-region` | StackIT region(s) to scan | No | All available regions |
+
+\* Required unless provided via environment variables.
+
+### Input Validation
+
+The StackIT provider performs comprehensive input validation:
+
+- **Service Account Credentials**:
+ - At least one of `service_account_key_path` (file path) or `service_account_key` (inline JSON) must be supplied; both empty raises `StackITNonExistentTokenError`
+ - When both are provided the inline content takes precedence
+ - The key file path is logged as-is; the inline content is redacted in the credentials box
+
+- **Project ID**:
+ - Must not be empty
+ - Must be a valid UUID format (e.g., `12345678-1234-1234-1234-123456789abc`)
+ - Validated using Python's UUID constructor
+
+Invalid credentials will result in clear error messages before any API calls are made.
+
+## Available Services
+
+### IaaS (Infrastructure as a Service)
+
+- **Service Class:** `IaaSService`
+- **Location:** [`prowler/providers/stackit/services/iaas/iaas_service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/services/iaas/iaas_service.py)
+- **SDK:** Uses the [stackit-iaas](https://pypi.org/project/stackit-iaas/) Python SDK
+- **Purpose:** Manages IaaS resources including security groups, servers, and network interfaces.
+
+**Supported Resources:**
+- Security Groups and Rules
+- Servers (Virtual Machines)
+- Network Interfaces (NICs)
+
+**Key Features:**
+- Automatic discovery of all security groups in the project
+- Security rule parsing with support for unrestricted access detection
+- Network interface analysis to determine whether security groups are in use
+- By default, reports only security groups attached to at least one NIC; `--scan-unused-services` includes unused security groups too
+
+## Available Checks
+
+The StackIT provider currently implements 4 security checks focused on network security:
+
+### 1. iaas_security_group_ssh_unrestricted
+
+- **Severity:** High
+- **Description:** Detects security groups that allow unrestricted SSH access (port 22) from the internet.
+- **Risk:** Unrestricted SSH access increases the attack surface and risk of brute-force attacks.
+- **Detection Logic:**
+ - Checks for ingress rules allowing TCP port 22
+ - Flags rules with `ip_range=None` or `ip_range="0.0.0.0/0"` or `ip_range="::/0"`
+ - Reports security groups attached to NICs by default, or all security groups when `--scan-unused-services` is enabled
+
+### 2. iaas_security_group_rdp_unrestricted
+
+- **Severity:** High
+- **Description:** Detects security groups that allow unrestricted RDP access (port 3389) from the internet.
+- **Risk:** Unrestricted RDP access enables potential unauthorized remote desktop access.
+- **Detection Logic:**
+ - Checks for ingress rules allowing TCP port 3389
+ - Flags unrestricted IP ranges (None, 0.0.0.0/0, ::/0)
+ - Reports security groups attached to NICs by default, or all security groups when `--scan-unused-services` is enabled
+
+### 3. iaas_security_group_database_unrestricted
+
+- **Severity:** High
+- **Description:** Detects security groups that allow unrestricted access to common database ports.
+- **Monitored Ports:**
+ - MySQL: 3306
+ - PostgreSQL: 5432
+ - MongoDB: 27017
+ - Redis: 6379
+ - SQL Server: 1433
+ - CouchDB: 5984
+- **Risk:** Unrestricted database access can lead to data breaches and unauthorized data access.
+
+### 4. iaas_security_group_all_traffic_unrestricted
+
+- **Severity:** Critical
+- **Description:** Detects security groups that allow all traffic from the internet.
+- **Detection Logic:**
+ - Checks for rules with `port_range=None` (all ports)
+ - Checks for rules with port range covering 0-65535 or 1-65535
+ - Flags unrestricted IP ranges
+ - Critical security misconfiguration requiring immediate remediation
+
+### Important Implementation Notes
+
+**Self-Referencing Security Group Rules:**
+
+Security group rules with `remoteSecurityGroupId` set are automatically filtered out from unrestricted access checks. These rules only allow traffic from instances within the same security group (self-referencing), not from the internet, and are therefore not flagged as security risks.
+
+**Rule Display Names:**
+
+All findings include user-friendly rule descriptions when available. If a security group rule has a description field set (the name shown in the StackIT UI), it will be displayed in the finding message along with the rule ID:
+- With description: `'Allow SSH from office' (sgr-abc123)`
+- Without description: `'sgr-abc123'`
+
+**Network Interface (NIC) Usage Filtering:**
+
+The IaaS service lists project NICs and records the security group IDs attached to them. Checks use that signal to decide whether a security group is in use:
+
+1. **Default behavior:** Report security groups attached to at least one NIC.
+2. **`--scan-unused-services`:** Report every security group, including unused ones.
+3. **FAIL logic:** Internet exposure is driven by security group rules that allow unrestricted source ranges, not by the presence of a public IP on the NIC.
+
+**Unrestricted IP Ranges:**
+
+The StackIT API represents "unrestricted" in two ways:
+- **`ip_range=null`**: No IP restriction specified (implicit unrestricted)
+- **`ip_range="0.0.0.0/0"` or `"::/0"`**: Explicitly configured to allow all IPs
+
+Both are flagged as unrestricted. A `null` value is **more permissive** than an explicit range and applies to all protocols/ports if other fields are also `null`.
+
+## Requirements
+
+### Python Version
+
+- **Minimum:** Python 3.10+
+- **Reason:** The StackIT SDK requires Python 3.10 or higher
+
+### Dependencies
+
+The StackIT provider requires the following Python packages (automatically installed with Prowler):
+
+- **stackit-core** (v0.2.0): Core SDK for StackIT API authentication and configuration
+- **stackit-iaas** (v1.4.0): IaaS service SDK for managing compute resources
+- **stackit-resourcemanager** (v0.8.0): Resource Manager SDK for fetching project metadata (e.g., project names)
+
+These dependencies are defined in `pyproject.toml` and installed automatically with:
+
+```bash
+poetry install
+```
+
+**Note:** The `stackit-resourcemanager` package enables automatic retrieval of project names for display in reports. If this package is not available, Prowler will still function normally but project names will be empty in the output.
+
+## Region Support
+
+### Supported Regions
+
+- **Available Regions:** `eu01` (Germany South) and `eu02` (Austria West)
+- **Default:** All scans use both `eu01` and `eu02` regions by default.
+
+### Multi-Region Scanning
+
+Prowler supports scanning multiple StackIT regions in a single execution. By default, it will scan all regions defined in the `stackit_regions_by_service.json` configuration file.
+
+### CLI Argument
+
+You can specify which regions to scan using the `--stackit-region` argument:
+
+```bash
+# Scan only eu01
+prowler stackit --stackit-region eu01
+
+# Scan both eu01 and eu02
+prowler stackit --stackit-region eu01 eu02
+```
+
+### Implementation Details
+
+- **Regional Clients:** Prowler generates a separate API client for each audited region.
+- **Service Iteration:** Each service (e.g., IaaS) iterates through the regional clients to fetch and audit resources.
+- **Identity Tracking:** The `audited_regions` are stored in the identity model for reporting.
+
+### Future Enhancements
+
+As StackIT adds more regions, they can be easily added to Prowler by updating the `prowler/providers/stackit/stackit_regions_by_service.json` file without requiring code changes.
+
+## Command Examples
+
+### Scan Specific Regions
+
+Scan only the `eu01` region:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --stackit-region eu01
+```
+
+Scan multiple regions:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --stackit-region eu01 eu02
+```
+
+### Scan Specific Checks
+
+Run only SSH unrestricted check:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --checks iaas_security_group_ssh_unrestricted
+```
+
+### Scan All Security Group Checks
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --services iaas
+```
+
+### Output Formats
+
+Generate JSON output:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --output-formats json
+```
+
+Generate HTML report:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --output-formats html
+```
+
+## Known Limitations
+
+### Current Limitations
+
+1. **Single Project Scope**: Only one project can be scanned at a time
+2. **Service Coverage**: Only the IaaS service is currently implemented
+3. **Check Coverage**: Limited to security group network security checks (4 checks total)
+4. **No Compliance Frameworks**: Compliance framework mappings are not yet implemented
+
+### Planned Enhancements
+
+- Multi-project scanning capability
+- Additional IaaS checks (volume encryption, server public IP exposure, backup status)
+- Compliance framework mappings (CIS, custom StackIT best practices)
+- StackIT CLI remediation examples in metadata
+
+## Troubleshooting
+
+### Authentication Errors
+
+**Error:** `StackIT service account key was rejected`
+
+**Solutions:**
+1. Re-issue the service account key in the StackIT Portal
+2. Verify the service account key file or inline JSON content is complete
+3. Check that the service account has the necessary permissions (`iaas.viewer` or `project.owner`)
+4. Ensure the service account key is provided through `STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_SERVICE_ACCOUNT_KEY`, or the matching CLI arguments
+
+**Error:** `StackIT credentials not found or are invalid`
+
+**Solutions:**
+1. Ensure the project ID and one service account credential source are provided
+2. Check that credentials are set via environment variables or command-line arguments
+3. Verify there are no extra spaces or newlines in the credentials
+
+**Error:** `Invalid StackIT project ID format`
+
+**Solutions:**
+1. Verify the project ID is a valid UUID format: `12345678-1234-1234-1234-123456789abc`
+2. Copy the project ID directly from the StackIT Portal
+3. Ensure there are no extra spaces or quotes around the UUID
+
+### API Connection Errors
+
+**Error:** `Failed to connect to StackIT API`
+
+**Solutions:**
+1. Check your internet connection
+2. Verify the StackIT API endpoint is accessible from your network
+3. Check if there are any firewall rules blocking HTTPS connections
+4. Review the full error message for specific API error codes
+
+**Error:** `HTTP 403 Forbidden`
+
+**Solutions:**
+1. Verify the service account has the correct permissions
+2. Ensure the project ID is correct and you have access to it
+3. Check that the service account is enabled (not disabled or expired)
+4. Verify the service account key has not been revoked
+
+**Error:** `HTTP 404 Not Found`
+
+**Solutions:**
+1. Verify the project ID exists and is correct
+2. Check that the IaaS service is enabled in your project
+3. Ensure you're using the correct region (eu01)
+
+### Empty Results
+
+**Issue:** No security groups or findings reported
+
+**Solutions:**
+1. Verify that security groups exist in your project
+2. Check that the IaaS service is properly configured
+3. Ensure the service account has `iaas.viewer` permission
+4. Check Prowler logs for any API errors (use `--log-level DEBUG`)
+
+### Debug Mode
+
+Enable debug logging for detailed troubleshooting:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --log-level DEBUG
+```
+
+This will show:
+- API authentication details (with inline service account keys redacted)
+- Resource discovery progress
+- Security rule parsing details
+- Any API errors or warnings
+
+## Specific Patterns in StackIT Services
+
+The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the currently implemented services in the following locations:
+
+- Directly in the code, in location [`prowler/providers/stackit/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services)
+- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
+
+The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other StackIT services as reference.
+
+### StackIT Service Common Patterns
+
+- Services communicate with StackIT using the StackIT Python SDK, you can find the documentation [here](https://github.com/stackitcloud/stackit-sdk-python).
+- Service constructors receive a `StackitProvider` instance and use it to access credentials, identity, and configuration.
+- The provider builds StackIT SDK `Configuration` objects from the service account key path or inline key content.
+- Resource containers **must** be initialized in the constructor, typically as lists or dictionaries.
+- Do not manipulate `os.environ` for credentials inside services. Use the provider session and SDK configuration helpers.
+- All StackIT resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes.
+- StackIT SDK calls are wrapped in try/except blocks, with specific handling for API errors, always logging errors.
+- **Centralized Error Handling**: Use `provider.handle_api_error(exception)` for consistent authentication error detection across all services.
+- **SDK Warning Suppression**: StackIT SDK prints deprecation warnings to stderr - use the `suppress_stderr()` context manager during SDK initialization and API calls.
+- **Unrestricted Access Detection**: In StackIT API, `None` values mean "allow all" (more permissive than explicit 0.0.0.0/0).
+ - `protocol=None` → All protocols allowed
+ - `ip_range=None` → All source IPs allowed (unrestricted!)
+ - `port_range=None` → All ports allowed
+ - `remote_security_group_id` set → Only allows traffic from the same security group (not unrestricted!)
+
+### IaaS Service Specific Patterns
+
+**Security Group Discovery:**
+```python
+# List all security groups
+security_groups = client.list_security_groups(
+ project_id=self.project_id,
+ region=region,
+)
+
+# List network interfaces to determine security group usage
+nics = client.list_project_nics(
+ project_id=self.project_id,
+ region=region,
+)
+
+# Checks report in-use security groups by default. Use --scan-unused-services
+# to include security groups that are not attached to any NIC.
+```
+
+**Centralized Authentication Error Handling:**
+```python
+def _handle_api_call(self, api_function, *args, **kwargs):
+ """Wrapper for API calls with centralized error handling."""
+ try:
+ with suppress_stderr(): # Suppress SDK warnings
+ return api_function(*args, **kwargs)
+ except Exception as e:
+ # Use centralized error handler from provider
+ self.provider.handle_api_error(e) # Detects 401 and raises StackITInvalidTokenError
+```
+
+**Unrestricted Access Detection:**
+```python
+def is_unrestricted(rule):
+ """Check if a rule allows unrestricted access."""
+ # Filter out self-referencing rules
+ if rule.remote_security_group_id is not None:
+ return False
+ # Check for unrestricted IP ranges
+ return rule.ip_range is None or rule.ip_range in ["0.0.0.0/0", "::/0"]
+
+def is_tcp(rule):
+ """Check if a rule applies to TCP protocol."""
+ # None means all protocols (including TCP)
+ return rule.protocol is None or rule.protocol.lower() in ["tcp", "all"]
+
+def includes_port(rule, port):
+ """Check if a rule includes a specific port."""
+ # None means all ports
+ if rule.port_range is None:
+ return True
+ return rule.port_range.min <= port <= rule.port_range.max
+```
+
+## Specific Patterns in StackIT Checks
+
+The StackIT checks pattern is described in [checks page](/developer-guide/checks). You can find all the currently implemented checks:
+
+- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted))
+- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
+
+The best reference to understand how to implement a new check is following the [check creation documentation](/developer-guide/checks#creating-a-check) and taking other similar StackIT checks as reference.
+
+### Check Report Class
+
+The `CheckReportStackIT` class models a single finding for a StackIT resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class.
+
+#### Purpose
+
+`CheckReportStackIT` extends the base report structure with StackIT-specific fields, enabling detailed tracking of the resource, project, and location associated with each finding.
+
+#### Constructor and Attribute Population
+
+When you instantiate `CheckReportStackIT`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its StackIT-specific attributes from the resource, using the following logic:
+
+- **`resource_id`**:
+ - Uses `resource.id` if present.
+ - Otherwise, uses `resource.resource_id` if present.
+ - Defaults to an empty string if none are available.
+
+- **`resource_name`**:
+ - Uses `resource.name` if present.
+ - Defaults to an empty string if not available.
+
+- **`project_id`**:
+ - Uses `resource.project_id` if present.
+ - Defaults to an empty string if not available (should be set in check logic).
+
+- **`location`**:
+ - Uses `resource.region` if present.
+ - Otherwise, uses `resource.location` if present.
+ - Defaults to an empty string if not available.
+
+If the resource object does not contain the required attributes, you must set them manually in the check logic.
+
+Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic.
+
+#### Example Usage
+
+```python
+from prowler.lib.check.models import CheckReportStackIT
+
+report = CheckReportStackIT(
+ metadata=self.metadata(),
+ resource=security_group
+)
+report.status = "FAIL"
+report.status_extended = f"Security group {security_group.name} allows unrestricted SSH access from the internet."
+report.resource_id = security_group.id
+report.resource_name = security_group.name
+report.project_id = security_group.project_id
+report.location = security_group.region
+```
+
+### Common Check Pattern
+
+```python
+from prowler.lib.check.models import Check, CheckReportStackIT
+from prowler.providers.stackit.services.iaas.iaas_client import iaas_client
+
+class iaas_security_group_ssh_unrestricted(Check):
+ """Check if IaaS security groups allow unrestricted SSH access."""
+
+ def execute(self):
+ findings = []
+
+ for security_group in iaas_client.security_groups:
+ if not (iaas_client.scan_unused_services or security_group.in_use):
+ continue
+
+ report = CheckReportStackIT(
+ metadata=self.metadata(),
+ resource=security_group
+ )
+ report.status = "PASS"
+ report.status_extended = f"Security group {security_group.name} does not allow unrestricted SSH access."
+
+ # Check each rule
+ for rule in security_group.rules:
+ if (rule.is_ingress() and
+ rule.is_tcp() and
+ rule.includes_port(22) and
+ rule.is_unrestricted()):
+ report.status = "FAIL"
+ report.status_extended = f"Security group {security_group.name} allows unrestricted SSH access from the internet."
+ break
+
+ findings.append(report)
+
+ return findings
+```
+
+## Resources
+
+### Official StackIT Documentation
+
+- **StackIT Portal**: [https://portal.stackit.cloud/](https://portal.stackit.cloud/)
+- **StackIT Documentation**: [https://docs.stackit.cloud/](https://docs.stackit.cloud/)
+- **StackIT API Documentation**: [https://docs.api.eu01.stackit.cloud/](https://docs.api.eu01.stackit.cloud/)
+
+### Python SDK
+
+- **StackIT Python SDK (GitHub)**: [https://github.com/stackitcloud/stackit-sdk-python](https://github.com/stackitcloud/stackit-sdk-python)
+- **stackit-core (PyPI)**: [https://pypi.org/project/stackit-core/](https://pypi.org/project/stackit-core/)
+- **stackit-iaas (PyPI)**: [https://pypi.org/project/stackit-iaas/](https://pypi.org/project/stackit-iaas/)
+- **IaaS Models**: [https://github.com/stackitcloud/stackit-sdk-python/tree/main/services/iaas/src/stackit/iaas/models](https://github.com/stackitcloud/stackit-sdk-python/tree/main/services/iaas/src/stackit/iaas/models)
+
+### Prowler Resources
+
+- **Provider Implementation**: [`prowler/providers/stackit/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/)
+- **IaaS Service**: [`prowler/providers/stackit/services/iaas/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services/iaas/)
+- **Prowler Hub**: [https://hub.prowler.com/](https://hub.prowler.com/)
+- **GitHub Issues**: [https://github.com/prowler-cloud/prowler/issues](https://github.com/prowler-cloud/prowler/issues)
+
+## Contributing
+
+If you'd like to contribute to the StackIT provider:
+
+1. **Add New Checks**: Follow the [check creation guide](/developer-guide/checks#creating-a-check) and use existing StackIT checks as templates
+2. **Enhance Services**: Implement additional IaaS resource discovery or add new services
+3. **Improve Documentation**: Add metadata enhancements, CLI remediation examples, or Terraform code samples
+4. **Report Issues**: Submit bug reports or feature requests on [GitHub](https://github.com/prowler-cloud/prowler/issues)
+
+### Quick Start for Contributors
+
+1. **Install dependencies**: `poetry install` (includes stackit-core and stackit-iaas)
+2. **Set credentials**: Export `STACKIT_SERVICE_ACCOUNT_KEY_PATH` and `STACKIT_PROJECT_ID`
+3. **Run checks**: `prowler stackit`
+4. **View code**: Start in `prowler/providers/stackit/`
+5. **Add checks**: Create new check directories under `services/iaas/`
+6. **Run tests**: `poetry run pytest tests/providers/stackit/ -v`
+
+### Code Quality Standards
+
+The StackIT provider should follow the same quality expectations as the rest of the Prowler SDK:
+
+- Keep service and check logic covered by unit tests.
+- Redact inline service account keys from generated output.
+- Keep documentation aligned with the implemented services and checks.
+- Follow existing provider, service, and check patterns before adding StackIT-specific abstractions.
diff --git a/docs/docs.json b/docs/docs.json
index 836ff50513..e31575878e 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -339,6 +339,13 @@
"user-guide/providers/scaleway/authentication"
]
},
+ {
+ "group": "StackIT",
+ "pages": [
+ "user-guide/providers/stackit/getting-started-stackit",
+ "user-guide/providers/stackit/authentication"
+ ]
+ },
{
"group": "Vercel",
"pages": [
@@ -401,7 +408,8 @@
"developer-guide/kubernetes-details",
"developer-guide/m365-details",
"developer-guide/github-details",
- "developer-guide/llm-details"
+ "developer-guide/llm-details",
+ "developer-guide/stackit-details"
]
},
{
diff --git a/docs/introduction.mdx b/docs/introduction.mdx
index dd05e3dbdb..fe530e4a4f 100644
--- a/docs/introduction.mdx
+++ b/docs/introduction.mdx
@@ -36,6 +36,7 @@ Prowler supports a wide range of providers organized by category:
| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI |
| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI |
| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) | [Contact us](https://prowler.com/contact) | Organizations | CLI |
+| [StackIT](/user-guide/providers/stackit/getting-started-stackit) | [Contact us](https://prowler.com/contact) | Projects | CLI |
### Infrastructure as Code Providers
diff --git a/docs/user-guide/providers/stackit/authentication.mdx b/docs/user-guide/providers/stackit/authentication.mdx
new file mode 100644
index 0000000000..dabc407c50
--- /dev/null
+++ b/docs/user-guide/providers/stackit/authentication.mdx
@@ -0,0 +1,100 @@
+---
+title: 'StackIT Authentication'
+---
+
+Prowler authenticates with StackIT using a **service account key file**. The StackIT SDK signs the RSA challenge in the key file and mints/refreshes access tokens internally for the life of the scan, so no manual token rotation is needed.
+
+## Service Account Key
+
+StackIT uses RSA key-pair based service account keys. They are issued once, must be stored securely, and are read by the SDK on every scan to mint short-lived access tokens transparently.
+
+### Option 1: Create the Key via the StackIT Portal
+
+1. Open the [StackIT Portal](https://portal.stackit.cloud/) and select your project.
+2. In the left sidebar, click **Service Accounts**.
+3. Create a service account if you do not have one already. Assign:
+ - `iaas.viewer` for the IaaS security group checks currently shipped, or
+ - `project.owner` if you want to cover any future service Prowler adds.
+4. Open the service account and go to **Service Account Keys**.
+5. Click **Create key** and choose **STACKIT-generated key pair** (recommended). Download the resulting JSON file and store it securely (for example, `~/.stackit/sa-key.json`). The private material is only shown once.
+
+### Option 2: Create the Key via the StackIT CLI
+
+```bash
+# Install the StackIT CLI from https://github.com/stackitcloud/stackit-cli first
+stackit service-account key create --email my-service-account@example.com
+```
+
+## Project ID
+
+Your StackIT project ID is a UUID. You can find it in:
+
+1. The portal URL when viewing the project: `https://portal.stackit.cloud/projects/{PROJECT_ID}/...`
+2. The project settings page
+3. `stackit project list`
+
+## Passing Credentials to Prowler
+
+You can give Prowler either the **path** to the key file on disk or the **inline JSON content** of the key. Both go through the same StackIT SDK flow and refresh access tokens internally.
+
+### Option A: Key File Path (workstation, persistent agents)
+
+Recommended when the key is stored on disk.
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+
+prowler stackit
+```
+
+Or as CLI flags:
+
+```bash
+prowler stackit \
+ --stackit-service-account-key-path ~/.stackit/sa-key.json \
+ --stackit-project-id 12345678-1234-1234-1234-123456789abc
+```
+
+
+Keep the key file outside of source control and lock it down with `chmod 600 ~/.stackit/sa-key.json`. Anyone with the JSON can mint access tokens for the service account.
+
+
+### Option B: Inline Key Content (CI/CD, secret managers)
+
+Recommended when the key is fetched at run time from a secret manager (GitHub Actions secret, AWS Secrets Manager, HashiCorp Vault, etc.) and you do not want to write it to disk.
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+
+prowler stackit
+```
+
+
+Prefer the `STACKIT_SERVICE_ACCOUNT_KEY` environment variable over the matching CLI flag (`--stackit-service-account-key`); passing the secret on the command line leaks it through process listings and shell history.
+
+
+When both the inline content and a key path are set, the inline content wins.
+
+## Credential Lookup Order
+
+Prowler resolves credentials in this order:
+
+1. CLI arguments: `--stackit-service-account-key`, `--stackit-service-account-key-path`, `--stackit-project-id`
+2. Environment variables: `STACKIT_SERVICE_ACCOUNT_KEY`, `STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_PROJECT_ID`
+
+When both the inline key and the key file path are set, the inline content takes precedence.
+
+## Token Lifetime
+
+Access tokens are minted on demand by the SDK from the key file and refreshed before they expire. There is nothing to rotate while Prowler is running.
+
+## Troubleshooting
+
+| Symptom | Likely Cause | Fix |
+|---------|--------------|-----|
+| `401 Unauthorized` during scan | Key file is missing fields, the public key is no longer registered, or the key was revoked | Re-issue the service account key in the StackIT portal and update `STACKIT_SERVICE_ACCOUNT_KEY_PATH` |
+| `403 Forbidden` during scan | Service account lacks role on the project | Re-check role assignment in the StackIT portal; `iaas.viewer` is the minimum for the shipped IaaS checks |
+| `StackIT project ID must be a valid UUID` | The project ID is not in UUID format | Copy the UUID from the portal URL or `stackit project list` |
+| `StackIT service account credentials are required` | None of the four credential inputs is set | Export `STACKIT_SERVICE_ACCOUNT_KEY_PATH` or `STACKIT_SERVICE_ACCOUNT_KEY` (or use their CLI counterparts) before running Prowler |
diff --git a/docs/user-guide/providers/stackit/getting-started-stackit.mdx b/docs/user-guide/providers/stackit/getting-started-stackit.mdx
new file mode 100644
index 0000000000..6c56008a89
--- /dev/null
+++ b/docs/user-guide/providers/stackit/getting-started-stackit.mdx
@@ -0,0 +1,141 @@
+---
+title: 'Getting Started With StackIT'
+---
+
+Prowler supports [StackIT](https://www.stackit.de/) from the CLI. This guide walks you through the requirements and how to run scans.
+
+
+StackIT support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact).
+
+
+## Prerequisites
+
+Before running Prowler with the StackIT provider, ensure you have:
+
+1. A StackIT account with at least one project
+2. A StackIT service account key file with permissions on the project (`iaas.viewer` is enough for the currently shipped IaaS checks; `project.owner` works for any future service). See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup.
+3. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli))
+
+## Prowler CLI
+
+### Step 1: Point Prowler at the Service Account Key
+
+Prowler authenticates with a StackIT service account key. The SDK signs the RSA challenge in the key and refreshes access tokens internally for the life of the scan, so there is no manual token rotation.
+
+**On a workstation or persistent agent** (key on disk):
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+```
+
+**In CI/CD** (key in a secret manager, never written to disk):
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+```
+
+CLI flags work too:
+
+```bash
+prowler stackit \
+ --stackit-service-account-key-path ~/.stackit/sa-key.json \
+ --stackit-project-id 12345678-1234-1234-1234-123456789abc
+```
+
+
+For the inline key, prefer the `STACKIT_SERVICE_ACCOUNT_KEY` env var over the matching CLI flag; passing the secret on the command line leaks it through process listings and shell history.
+
+Keep the key file outside of source control and lock it down with `chmod 600 ~/.stackit/sa-key.json`. Anyone with the JSON can mint access tokens for the service account.
+
+
+### Step 2: Run Your First Scan
+
+```bash
+prowler stackit
+```
+
+Prowler will discover and audit the project's IaaS security groups across the available StackIT regions.
+
+**Scan specific regions:**
+
+```bash
+prowler stackit --stackit-region eu01 eu02
+```
+
+**Run specific security checks:**
+
+```bash
+prowler stackit --checks iaas_security_group_ssh_unrestricted
+
+# List all available checks
+prowler stackit --list-checks
+```
+
+**Filter by check severity:**
+
+```bash
+prowler stackit --severity critical high
+```
+
+**Generate specific output formats:**
+
+```bash
+# JSON only
+prowler stackit --output-modes json
+
+# CSV and HTML
+prowler stackit --output-modes csv html
+
+# Custom output directory
+prowler stackit --output-directory /path/to/reports/
+```
+
+**Use a mutelist to suppress findings:**
+
+```yaml
+# mutelist.yaml
+Mutelist:
+ Accounts:
+ "12345678-1234-1234-1234-123456789abc":
+ Checks:
+ iaas_security_group_ssh_unrestricted:
+ Regions:
+ - "*"
+ Resources:
+ - "test-sg-id"
+ Tags: []
+```
+
+```bash
+prowler stackit --mutelist-file mutelist.yaml
+```
+
+### Step 3: Review the Results
+
+Prowler outputs findings to the console and writes reports to the `output/` directory by default:
+
+- CSV: `output/prowler-output-stackit-{project_id}-{timestamp}.csv`
+- JSON: `output/prowler-output-stackit-{project_id}-{timestamp}.json`
+- HTML: `output/prowler-output-stackit-{project_id}-{timestamp}.html`
+
+## Supported StackIT Services
+
+| Service | StackIT API | Description | Example Checks |
+|---------|-------------|-------------|----------------|
+| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted` |
+
+Additional services will be added in future releases. Track progress in the [Prowler release notes](https://github.com/prowler-cloud/prowler/releases).
+
+## Troubleshooting
+
+### Authentication Errors
+
+If the scan fails with a 401 error, the service account key is no longer valid (revoked, rotated or the key file is incomplete). Re-issue the key in the [StackIT portal](https://portal.stackit.cloud/) and update `STACKIT_SERVICE_ACCOUNT_KEY_PATH`.
+
+### Permission Errors
+
+If checks fail with a 403 error, the service account is missing the required role on the project. Re-check the role assignment in the StackIT portal (`iaas.viewer` is the minimum for the shipped IaaS checks).
+
+For detailed setup steps, see the [Authentication guide](/user-guide/providers/stackit/authentication).
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index dfc2e9ca2e..fedc6eecb8 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358)
- AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353)
- `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334)
+- StackIT provider now authenticates with a service account key, either as a file path (`--stackit-service-account-key-path` / `STACKIT_SERVICE_ACCOUNT_KEY_PATH`) or as inline JSON content (`--stackit-service-account-key` / `STACKIT_SERVICE_ACCOUNT_KEY`, intended for CI/CD with a secret manager); the StackIT SDK refreshes access tokens internally, replacing the short-lived `STACKIT_API_TOKEN` flow [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237)
- 8 Rules service checks for Google Workspace provider using the Cloud Identity Policy API [(#11379)](https://github.com/prowler-cloud/prowler/pull/11379)
- 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356)
diff --git a/prowler/__main__.py b/prowler/__main__.py
index 3328905153..a9d794c2d6 100644
--- a/prowler/__main__.py
+++ b/prowler/__main__.py
@@ -158,6 +158,7 @@ from prowler.providers.okta.models import OktaOutputOptions
from prowler.providers.openstack.models import OpenStackOutputOptions
from prowler.providers.oraclecloud.models import OCIOutputOptions
from prowler.providers.scaleway.models import ScalewayOutputOptions
+from prowler.providers.stackit.models import StackITOutputOptions
from prowler.providers.vercel.models import VercelOutputOptions
@@ -416,6 +417,10 @@ def prowler():
output_options = OCIOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
+ elif provider == "stackit":
+ output_options = StackITOutputOptions(
+ args, bulk_checks_metadata, global_provider.identity
+ )
elif provider == "alibabacloud":
output_options = AlibabaCloudOutputOptions(
args, bulk_checks_metadata, global_provider.identity
diff --git a/prowler/compliance/stackit/__init__.py b/prowler/compliance/stackit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/config/config.py b/prowler/config/config.py
index 2f4d18e32f..53eb5e3e37 100644
--- a/prowler/config/config.py
+++ b/prowler/config/config.py
@@ -78,6 +78,7 @@ class Provider(str, Enum):
SCALEWAY = "scaleway"
VERCEL = "vercel"
OKTA = "okta"
+ STACKIT = "stackit"
# Compliance
diff --git a/prowler/config/stackit_mutelist_example.yaml b/prowler/config/stackit_mutelist_example.yaml
new file mode 100644
index 0000000000..40b04a2731
--- /dev/null
+++ b/prowler/config/stackit_mutelist_example.yaml
@@ -0,0 +1,26 @@
+### Project, Check and/or Region can be * to apply for all the cases.
+### Project ==
+### 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 Projects, Regions, Resources and/or Tags.
+########################### MUTELIST EXAMPLE ###########################
+Mutelist:
+ Accounts:
+ "project_id_1":
+ Checks:
+ "iaas_security_group_ssh_unrestricted":
+ Regions:
+ - "*"
+ Resources:
+ - "sg-production-ssh"
+ - "sg-development-rdp"
+ Tags:
+ - "environment=dev"
+ "project_id_2":
+ Checks:
+ "*":
+ Regions:
+ - "eu01"
+ Resources:
+ - ".*-test$"
diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py
index d034c5e550..d97be78cbe 100644
--- a/prowler/lib/check/models.py
+++ b/prowler/lib/check/models.py
@@ -1230,6 +1230,31 @@ class CheckReportNHN(Check_Report):
self.location = getattr(resource, "location", "kr1")
+@dataclass
+class CheckReportStackIT(Check_Report):
+ """Contains the StackIT Check's finding information."""
+
+ resource_name: str
+ resource_id: str
+ project_id: str
+ location: str
+
+ def __init__(self, metadata: Dict, resource: Any) -> None:
+ """Initialize the StackIT Check's finding information.
+
+ Args:
+ metadata: The metadata of the check.
+ resource: Basic information about the resource. Defaults to None.
+ """
+ super().__init__(metadata, resource)
+ self.resource_name = getattr(
+ resource, "name", getattr(resource, "resource_name", "")
+ )
+ self.resource_id = getattr(resource, "id", getattr(resource, "resource_id", ""))
+ self.project_id = getattr(resource, "project_id", "")
+ self.location = getattr(resource, "region", getattr(resource, "location", ""))
+
+
@dataclass
class CheckReportOpenStack(Check_Report):
"""Contains the OpenStack Check's finding information."""
diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py
index 6045302a43..449c15a22c 100644
--- a/prowler/lib/cli/parser.py
+++ b/prowler/lib/cli/parser.py
@@ -29,10 +29,10 @@ class ProwlerArgumentParser:
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
- usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel,dashboard,iac,image,llm} ...",
+ 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} ...",
epilog="""
Available Cloud Providers:
- {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel}
+ {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -44,6 +44,7 @@ Available Cloud Providers:
cloudflare Cloudflare Provider
oraclecloud Oracle Cloud Infrastructure Provider
openstack OpenStack Provider
+ stackit StackIT Provider
alibabacloud Alibaba Cloud Provider
iac IaC Provider
llm LLM Provider (Beta)
diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py
index 8ca01bc9c5..950460e4e3 100644
--- a/prowler/lib/outputs/finding.py
+++ b/prowler/lib/outputs/finding.py
@@ -342,6 +342,20 @@ class Finding(BaseModel):
output_data["resource_uid"] = check_output.resource_id
output_data["region"] = check_output.location
+ elif provider.type == "stackit":
+ output_data["auth_method"] = getattr(
+ provider, "auth_method", "api_token"
+ )
+ output_data["account_uid"] = get_nested_attribute(
+ provider, "identity.project_id"
+ )
+ output_data["account_name"] = get_nested_attribute(
+ provider, "identity.project_name"
+ )
+ output_data["resource_name"] = check_output.resource_name
+ output_data["resource_uid"] = check_output.resource_id
+ output_data["region"] = check_output.location
+
elif provider.type == "iac":
output_data["auth_method"] = provider.auth_method
provider_uid = getattr(provider, "provider_uid", None)
@@ -576,6 +590,8 @@ class Finding(BaseModel):
finding.subscription = list(provider.identity.subscriptions.keys())[0]
elif provider.type == "gcp":
finding.project_id = list(provider.projects.keys())[0]
+ elif provider.type == "stackit":
+ finding.project_id = provider.identity.project_id
elif provider.type == "iac":
# For IaC, we don't have resource_line_range in the Finding model
# It would need to be extracted from the resource metadata if needed
diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py
index 8685d711ce..33f7ef4934 100644
--- a/prowler/lib/outputs/html/html.py
+++ b/prowler/lib/outputs/html/html.py
@@ -1076,6 +1076,73 @@ class HTML(Output):
)
return ""
+ @staticmethod
+ def get_stackit_assessment_summary(provider: Provider) -> str:
+ """
+ get_stackit_assessment_summary gets the HTML assessment summary for the StackIT provider
+
+ Args:
+ provider (Provider): the StackIT provider object
+
+ Returns:
+ str: HTML assessment summary for the StackIT provider
+ """
+ try:
+ project_id = getattr(provider.identity, "project_id", "unknown")
+ project_name = getattr(provider.identity, "project_name", "")
+ audited_regions = getattr(provider.identity, "audited_regions", set())
+
+ project_name_item = (
+ f"""
+
+ Project Name: {project_name}
+ """
+ if project_name
+ else ""
+ )
+
+ regions_item = (
+ f"""
+
+ Regions: {", ".join(sorted(audited_regions))}
+ """
+ if audited_regions
+ else ""
+ )
+
+ return f"""
+
+
+
+
+ -
+ Project ID: {project_id}
+
+ {project_name_item}
+ {regions_item}
+
+
+
+
+
+
+
+ -
+ Authentication Type: Service Account Key
+
+
+
+
"""
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
+ )
+ return ""
+
@staticmethod
def get_cloudflare_assessment_summary(provider: Provider) -> str:
"""
diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py
index d0ec605d4b..e4d935e3cf 100644
--- a/prowler/lib/outputs/outputs.py
+++ b/prowler/lib/outputs/outputs.py
@@ -24,6 +24,8 @@ def stdout_report(finding, color, verbose, status, fix):
details = finding.location
if finding.check_metadata.Provider == "nhn":
details = finding.location
+ if finding.check_metadata.Provider == "stackit":
+ details = finding.location
if finding.check_metadata.Provider == "llm":
details = finding.check_metadata.CheckID
if finding.check_metadata.Provider == "iac":
diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py
index 263c21b1e5..e76728ccc8 100644
--- a/prowler/lib/outputs/summary_table.py
+++ b/prowler/lib/outputs/summary_table.py
@@ -70,6 +70,13 @@ def display_summary_table(
elif provider.type == "nhn":
entity_type = "Tenant Domain"
audited_entities = provider.identity.tenant_domain
+ elif provider.type == "stackit":
+ if provider.identity.project_name:
+ entity_type = "Project"
+ audited_entities = provider.identity.project_name
+ else:
+ entity_type = "Project ID"
+ audited_entities = provider.identity.project_id
elif provider.type == "iac":
if provider.scan_repository_url:
entity_type = "Repository"
diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py
index d76c1b6f2a..d11b015d54 100644
--- a/prowler/providers/common/provider.py
+++ b/prowler/providers/common/provider.py
@@ -256,6 +256,25 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
+ elif "stackit" in provider_class_name.lower():
+ provider_class(
+ project_id=arguments.stackit_project_id,
+ service_account_key_path=getattr(
+ arguments, "stackit_service_account_key_path", None
+ ),
+ service_account_key=getattr(
+ arguments, "stackit_service_account_key", None
+ ),
+ regions=(
+ set(arguments.stackit_region)
+ if arguments.stackit_region
+ else None
+ ),
+ scan_unused_services=arguments.scan_unused_services,
+ config_path=arguments.config_file,
+ mutelist_path=arguments.mutelist_file,
+ fixer_config=fixer_config,
+ )
elif "github" in provider_class_name.lower():
orgs = []
repos = []
diff --git a/prowler/providers/stackit/__init__.py b/prowler/providers/stackit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/exceptions/__init__.py b/prowler/providers/stackit/exceptions/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/exceptions/exceptions.py b/prowler/providers/stackit/exceptions/exceptions.py
new file mode 100644
index 0000000000..246abb337c
--- /dev/null
+++ b/prowler/providers/stackit/exceptions/exceptions.py
@@ -0,0 +1,99 @@
+from prowler.exceptions.exceptions import ProwlerException
+
+
+# Exceptions codes from 16000 to 16999 are reserved for StackIT exceptions
+class StackITBaseException(ProwlerException):
+ """Base class for StackIT Errors."""
+
+ STACKIT_ERROR_CODES = {
+ (16001, "StackITNonExistentTokenError"): {
+ "message": "A StackIT service account key file is required to authenticate against StackIT",
+ "remediation": "Set --stackit-service-account-key-path or the STACKIT_SERVICE_ACCOUNT_KEY_PATH environment variable to a valid service account key JSON file.",
+ },
+ (16002, "StackITInvalidTokenError"): {
+ "message": "StackIT service account key was rejected or lacks permissions",
+ "remediation": "Verify the service account key file is current, has not been revoked, and that the service account has the required roles on the project.",
+ },
+ (16003, "StackITSetUpSessionError"): {
+ "message": "Error setting up StackIT session",
+ "remediation": "Check the session setup and ensure the StackIT SDK is properly configured.",
+ },
+ (16004, "StackITSetUpIdentityError"): {
+ "message": "StackIT identity setup error due to bad credentials",
+ "remediation": "Check credentials and ensure they are properly set up for StackIT.",
+ },
+ (16005, "StackITInvalidProjectIdError"): {
+ "message": "The provided project ID is not valid or not accessible",
+ "remediation": "Check the project ID and ensure you have access to it with the provided credentials.",
+ },
+ (16006, "StackITAPIError"): {
+ "message": "Error calling StackIT API",
+ "remediation": "Check the API endpoint and ensure the service is accessible. Verify network connectivity.",
+ },
+ }
+
+ def __init__(self, code, file=None, original_exception=None, message=None):
+ provider = "StackIT"
+ # Clone the catalog entry so per-instance message overrides do not
+ # mutate the class-level dict and bleed into later exceptions raised
+ # in the same process.
+ base_info = self.STACKIT_ERROR_CODES.get((code, self.__class__.__name__))
+ error_info = dict(base_info) if base_info else None
+ if message and error_info is not None:
+ error_info["message"] = message
+ super().__init__(
+ code=code,
+ source=provider,
+ file=file,
+ original_exception=original_exception,
+ error_info=error_info,
+ )
+
+
+class StackITCredentialsError(StackITBaseException):
+ """Base class for StackIT credentials errors."""
+
+ def __init__(self, code, file=None, original_exception=None, message=None):
+ super().__init__(code, file, original_exception, message)
+
+
+class StackITNonExistentTokenError(StackITCredentialsError):
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 16001, file=file, original_exception=original_exception, message=message
+ )
+
+
+class StackITInvalidTokenError(StackITCredentialsError):
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 16002, file=file, original_exception=original_exception, message=message
+ )
+
+
+class StackITSetUpSessionError(StackITCredentialsError):
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 16003, file=file, original_exception=original_exception, message=message
+ )
+
+
+class StackITSetUpIdentityError(StackITCredentialsError):
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 16004, file=file, original_exception=original_exception, message=message
+ )
+
+
+class StackITInvalidProjectIdError(StackITCredentialsError):
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 16005, file=file, original_exception=original_exception, message=message
+ )
+
+
+class StackITAPIError(StackITBaseException):
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 16006, file=file, original_exception=original_exception, message=message
+ )
diff --git a/prowler/providers/stackit/lib/__init__.py b/prowler/providers/stackit/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/lib/arguments/__init__.py b/prowler/providers/stackit/lib/arguments/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/lib/arguments/arguments.py b/prowler/providers/stackit/lib/arguments/arguments.py
new file mode 100644
index 0000000000..afabc41fe8
--- /dev/null
+++ b/prowler/providers/stackit/lib/arguments/arguments.py
@@ -0,0 +1,60 @@
+from prowler.providers.stackit.stackit_provider import StackitProvider
+
+SENSITIVE_ARGUMENTS = frozenset({"--stackit-service-account-key"})
+
+
+def init_parser(self):
+ """Init the StackIT Provider CLI parser"""
+ stackit_parser = self.subparsers.add_parser(
+ "stackit", parents=[self.common_providers_parser], help="StackIT Provider"
+ )
+
+ # Authentication
+ stackit_auth_subparser = stackit_parser.add_argument_group("Authentication")
+ stackit_auth_subparser.add_argument(
+ "--stackit-project-id",
+ nargs="?",
+ default=None,
+ help="StackIT Project ID to audit (alternatively set via STACKIT_PROJECT_ID environment variable)",
+ )
+ stackit_auth_subparser.add_argument(
+ "--stackit-service-account-key-path",
+ nargs="?",
+ default=None,
+ help=(
+ "Path to a StackIT service account key JSON file. The SDK signs the RSA "
+ "challenge in the key and mints/refreshes access tokens internally for "
+ "the life of the scan. Alternatively set via the "
+ "STACKIT_SERVICE_ACCOUNT_KEY_PATH environment variable."
+ ),
+ )
+ stackit_auth_subparser.add_argument(
+ "--stackit-service-account-key",
+ nargs="?",
+ default=None,
+ help=(
+ "Inline content of a StackIT service account key (JSON). Useful in "
+ "CI/CD where the secret comes from a secret manager and you do not "
+ "want to write it to disk. Prefer the STACKIT_SERVICE_ACCOUNT_KEY "
+ "environment variable over this flag to avoid leaking the key "
+ "through process listings or shell history."
+ ),
+ )
+
+ stackit_parser.add_argument(
+ "--stackit-region",
+ "-r",
+ nargs="+",
+ help="STACKIT region(s) to scan (default: all available regions)",
+ choices=StackitProvider.get_regions(),
+ default=None,
+ )
+
+ scan_unused_services_subparser = stackit_parser.add_argument_group(
+ "Scan Unused Services"
+ )
+ scan_unused_services_subparser.add_argument(
+ "--scan-unused-services",
+ action="store_true",
+ help="Scan unused services",
+ )
diff --git a/prowler/providers/stackit/lib/mutelist/__init__.py b/prowler/providers/stackit/lib/mutelist/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/lib/mutelist/mutelist.py b/prowler/providers/stackit/lib/mutelist/mutelist.py
new file mode 100644
index 0000000000..5711392849
--- /dev/null
+++ b/prowler/providers/stackit/lib/mutelist/mutelist.py
@@ -0,0 +1,23 @@
+from prowler.lib.check.models import CheckReportStackIT
+from prowler.lib.mutelist.mutelist import Mutelist
+from prowler.lib.outputs.utils import unroll_dict, unroll_tags
+
+
+class StackITMutelist(Mutelist):
+ def is_finding_muted(self, finding: CheckReportStackIT) -> bool:
+ """
+ Determines if a StackIT finding is muted based on mutelist rules.
+
+ Args:
+ finding: A CheckReportStackIT finding object
+
+ Returns:
+ bool: True if the finding is muted, False otherwise
+ """
+ return self.is_muted(
+ finding.project_id,
+ finding.check_metadata.CheckID,
+ finding.location,
+ finding.resource_name,
+ unroll_dict(unroll_tags(finding.resource_tags)),
+ )
diff --git a/prowler/providers/stackit/lib/service/__init__.py b/prowler/providers/stackit/lib/service/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/models.py b/prowler/providers/stackit/models.py
new file mode 100644
index 0000000000..d069011083
--- /dev/null
+++ b/prowler/providers/stackit/models.py
@@ -0,0 +1,48 @@
+from pydantic.v1 import BaseModel
+
+from prowler.config.config import output_file_timestamp
+from prowler.providers.common.models import ProviderOutputOptions
+
+
+class StackITIdentityInfo(BaseModel):
+ """
+ StackITIdentityInfo holds basic identity fields for the StackIT provider.
+
+ Attributes:
+ - project_id (str): The StackIT project ID being audited.
+ - project_name (str): The name of the StackIT project (fetched from Resource Manager API).
+ """
+
+ project_id: str
+ project_name: str = ""
+ audited_regions: set = set()
+
+
+class StackITOutputOptions(ProviderOutputOptions):
+ """
+ StackITOutputOptions overrides ProviderOutputOptions for StackIT-specific output logic.
+ Generates a filename that includes the StackIT project_id.
+
+ Attributes inherited from ProviderOutputOptions:
+ - output_filename (str): The base filename used for generated reports.
+ - output_directory (str): The directory to store the output files.
+ - ... see ProviderOutputOptions for more details.
+
+ Methods:
+ - __init__: Customizes the output filename logic for StackIT.
+ """
+
+ def __init__(self, arguments, bulk_checks_metadata, identity: StackITIdentityInfo):
+ super().__init__(arguments, bulk_checks_metadata)
+
+ # If --output-filename is not specified, build a default name.
+ if not getattr(arguments, "output_filename", None):
+ # If project_id exists, include it in the filename (e.g., prowler-output-stackit--20230101)
+ if identity.project_id:
+ self.output_filename = f"prowler-output-stackit-{identity.project_id}-{output_file_timestamp}"
+ # Otherwise just 'prowler-output-stackit-'
+ else:
+ self.output_filename = f"prowler-output-stackit-{output_file_timestamp}"
+ # If --output-filename was explicitly given, respect that
+ else:
+ self.output_filename = arguments.output_filename
diff --git a/prowler/providers/stackit/services/__init__.py b/prowler/providers/stackit/services/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/services/iaas/__init__.py b/prowler/providers/stackit/services/iaas/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/services/iaas/iaas_client.py b/prowler/providers/stackit/services/iaas/iaas_client.py
new file mode 100644
index 0000000000..eca20c46b4
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.stackit.services.iaas.iaas_service import IaaSService
+
+iaas_client = IaaSService(Provider.get_global_provider())
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json
new file mode 100644
index 0000000000..a6bcd648bd
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "stackit",
+ "CheckID": "iaas_security_group_all_traffic_unrestricted",
+ "CheckTitle": "IaaS security groups do not allow unrestricted access to all ports",
+ "CheckType": [],
+ "ServiceName": "iaas",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "Security groups should not allow unrestricted access to all ports from the public internet (`0.0.0.0/0` or `::/0`). This includes rules with no port range specified or rules covering the full port range (`0-65535` or `1-65535`). Allowing all ports **exposes every service** running on the instances to potential attacks.",
+ "Risk": "Allowing unrestricted access to all ports from the internet exposes **all services and applications** to potential attacks, **unauthorized access**, and security breaches. This effectively **bypasses the security group's purpose** as a firewall.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.stackit.cloud/products/network/core-networking/security-groups/",
+ "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows all ports and protocols from 0.0.0.0/0 or ::/0. 3. Delete the broad rule and replace it with granular rules that open only the specific ports each service needs. 4. Restrict the source of every rule to trusted IP ranges following least privilege. 5. Re-run Prowler to confirm the finding is resolved.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Follow the **principle of least privilege** by only allowing the specific ports that are required. Create **granular rules** for each service and restrict access to trusted IP addresses or ranges.",
+ "Url": "https://hub.prowler.com/check/iaas_security_group_all_traffic_unrestricted"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "This check identifies security groups that allow all ports from the public internet. It flags rules with no port range specified or rules covering the full port range (0-65535 or 1-65535), regardless of the specific protocol. Security groups should implement specific rules for each required service rather than allowing all ports."
+}
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py
new file mode 100644
index 0000000000..1b2a7d02f3
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py
@@ -0,0 +1,67 @@
+from prowler.lib.check.models import Check, CheckReportStackIT
+from prowler.providers.stackit.services.iaas.iaas_client import iaas_client
+
+
+class iaas_security_group_all_traffic_unrestricted(Check):
+ """
+ Check if IaaS security groups allow unrestricted access to all ports.
+
+ This check verifies that security groups do not allow all ports
+ from the public internet (0.0.0.0/0 or ::/0). This includes rules
+ with no port range specified or rules covering the full port range
+ (0-65535 or 1-65535), regardless of the specific protocol.
+ """
+
+ def execute(self):
+ """
+ Execute the check for all security groups in the StackIT project.
+
+ Returns:
+ list: A list of CheckReportStackIT findings
+ """
+ findings = []
+
+ for security_group in iaas_client.security_groups:
+ if not (iaas_client.scan_unused_services or security_group.in_use):
+ continue
+ unrestricted_rules = []
+
+ # Check each ingress rule
+ for rule in security_group.rules:
+ # Only check ingress rules that are unrestricted
+ if rule.is_ingress() and rule.is_unrestricted():
+ # Check if rule allows all traffic (no port restrictions or all protocols)
+ if rule.port_range_min is None or rule.port_range_max is None:
+ # No port range specified - allows all ports
+ unrestricted_rules.append(
+ f"Rule {rule.get_rule_display_name()} allows all ports ({rule.protocol or 'all protocols'}) from {rule.get_ip_range_display()}"
+ )
+ elif (
+ rule.port_range_min == 0 or rule.port_range_min == 1
+ ) and rule.port_range_max >= 65535:
+ # Port range covers all or nearly all ports
+ unrestricted_rules.append(
+ f"Rule {rule.get_rule_display_name()} allows all ports (1-65535) ({rule.protocol or 'all protocols'}) from {rule.get_ip_range_display()}"
+ )
+
+ # Create a finding report for this security group
+ report = CheckReportStackIT(
+ metadata=self.metadata(),
+ resource=security_group,
+ )
+
+ if unrestricted_rules:
+ report.status = "FAIL"
+ rules_list = "; ".join(unrestricted_rules)
+ report.status_extended = f"Security group {security_group.name} allows unrestricted access to all traffic: {rules_list}."
+ else:
+ report.status = "PASS"
+ report.status_extended = f"Security group {security_group.name} does not allow unrestricted access to all traffic."
+
+ report.resource_id = security_group.id
+ report.resource_name = security_group.name
+ report.location = security_group.region
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json
new file mode 100644
index 0000000000..2337e2e01c
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "stackit",
+ "CheckID": "iaas_security_group_database_unrestricted",
+ "CheckTitle": "IaaS security groups do not allow unrestricted database access",
+ "CheckType": [],
+ "ServiceName": "iaas",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "Security groups should not allow unrestricted access to database ports from the public internet (`0.0.0.0/0` or `::/0`). This includes MySQL (`3306`), PostgreSQL (`5432`), MongoDB (`27017`), Redis (`6379`), SQL Server (`1433`), and CouchDB (`5984`). Unrestricted database access can lead to **data breaches** and **unauthorized access**.",
+ "Risk": "Allowing unrestricted database access from the internet exposes sensitive data to potential **breaches**, **unauthorized access**, **data exfiltration**, and malicious attacks. Databases often contain critical business data and should **never** be directly accessible from the public internet.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.stackit.cloud/products/network/core-networking/security-groups/",
+ "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rules that allow database ports (3306, 5432, 27017, 6379, 1433, 5984) from 0.0.0.0/0 or ::/0. 3. Delete those rules or restrict their source to the specific application servers or trusted IP ranges. 4. Prefer private networking for database connectivity and do not expose database ports to the internet. 5. Re-run Prowler to confirm the finding is resolved.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "**Restrict database access** to specific application servers or trusted IP ranges. Use **private networks** for database connectivity and implement additional security layers such as VPNs, private endpoints, or application-level authentication.",
+ "Url": "https://hub.prowler.com/check/iaas_security_group_database_unrestricted"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "This check covers common database ports: MySQL (3306), PostgreSQL (5432), MongoDB (27017), Redis (6379), SQL Server (1433), and CouchDB (5984). Databases should always be placed in private networks and accessed through secure channels."
+}
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py
new file mode 100644
index 0000000000..e777cbb17f
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py
@@ -0,0 +1,74 @@
+from prowler.lib.check.models import Check, CheckReportStackIT
+from prowler.providers.stackit.services.iaas.iaas_client import iaas_client
+
+# Database ports to check
+DATABASE_PORTS = {
+ 3306: "MySQL",
+ 5432: "PostgreSQL",
+ 27017: "MongoDB",
+ 6379: "Redis",
+ 1433: "SQL Server",
+ 5984: "CouchDB",
+}
+
+
+class iaas_security_group_database_unrestricted(Check):
+ """
+ Check if IaaS security groups allow unrestricted database access.
+
+ This check verifies that security groups do not allow database ports
+ (MySQL, PostgreSQL, MongoDB, Redis, SQL Server, CouchDB) access
+ from the public internet (0.0.0.0/0 or ::/0).
+ """
+
+ def execute(self):
+ """
+ Execute the check for all security groups in the StackIT project.
+
+ Returns:
+ list: A list of CheckReportStackIT findings
+ """
+ findings = []
+
+ for security_group in iaas_client.security_groups:
+ if not (iaas_client.scan_unused_services or security_group.in_use):
+ continue
+ exposed_databases = set()
+ exposing_rule = None
+
+ # Check each ingress rule
+ for rule in security_group.rules:
+ # Only check ingress TCP rules that are unrestricted
+ if rule.is_ingress() and rule.is_tcp() and rule.is_unrestricted():
+ # Check if rule allows any database ports
+ for port, db_name in DATABASE_PORTS.items():
+ if rule.includes_port(port):
+ exposed_databases.add(f"{db_name} (port {port})")
+ # Track the first exposing rule for the message
+ if exposed_databases and not exposing_rule:
+ exposing_rule = rule
+
+ # Create a finding report for this security group
+ report = CheckReportStackIT(
+ metadata=self.metadata(),
+ resource=security_group,
+ )
+
+ if exposed_databases:
+ report.status = "FAIL"
+ databases_list = ", ".join(sorted(exposed_databases))
+ report.status_extended = (
+ f"Security group {security_group.name} allows unrestricted database access "
+ f"to: {databases_list} from {exposing_rule.get_ip_range_display()} via rule {exposing_rule.get_rule_display_name()}."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = f"Security group {security_group.name} does not allow unrestricted database access."
+
+ report.resource_id = security_group.id
+ report.resource_name = security_group.name
+ report.location = security_group.region
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json
new file mode 100644
index 0000000000..e2b7cc527c
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "stackit",
+ "CheckID": "iaas_security_group_rdp_unrestricted",
+ "CheckTitle": "IaaS security groups do not allow unrestricted RDP access",
+ "CheckType": [],
+ "ServiceName": "iaas",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "Security groups should not allow unrestricted RDP access (port `3389`) from the public internet (`0.0.0.0/0` or `::/0`). Unrestricted RDP access increases the attack surface and can lead to **unauthorized access**, **brute force attacks**, and potential system compromise.",
+ "Risk": "Allowing unrestricted RDP access from the internet exposes Windows servers to potential **unauthorized access attempts**, **brute force attacks**, **ransomware**, and security breaches. Attackers can exploit weak credentials or vulnerabilities.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.stackit.cloud/products/network/core-networking/security-groups/",
+ "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows TCP port 3389 from 0.0.0.0/0 or ::/0. 3. Delete that rule or edit its source to the specific IP ranges that require RDP access. 4. If administration from anywhere is required, expose RDP only through a bastion host or VPN instead of the public internet. 5. Re-run Prowler to confirm the finding is resolved.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "**Restrict RDP access** to specific IP addresses or ranges that require administrative access. Use **bastion hosts** or **VPN connections** for secure remote access instead of exposing RDP directly to the internet.",
+ "Url": "https://hub.prowler.com/check/iaas_security_group_rdp_unrestricted"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "This check focuses on ingress rules that allow RDP (port 3389) from 0.0.0.0/0 or ::/0. Consider implementing network-level authentication and regular security audits."
+}
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py
new file mode 100644
index 0000000000..839fdfabe3
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py
@@ -0,0 +1,51 @@
+from prowler.lib.check.models import Check, CheckReportStackIT
+from prowler.providers.stackit.services.iaas.iaas_client import iaas_client
+
+
+class iaas_security_group_rdp_unrestricted(Check):
+ """
+ Check if IaaS security groups allow unrestricted RDP access.
+
+ This check verifies that security groups do not allow RDP (port 3389)
+ access from the public internet (0.0.0.0/0 or ::/0).
+ """
+
+ def execute(self):
+ """
+ Execute the check for all security groups in the StackIT project.
+
+ Returns:
+ list: A list of CheckReportStackIT findings
+ """
+ findings = []
+
+ for security_group in iaas_client.security_groups:
+ if not (iaas_client.scan_unused_services or security_group.in_use):
+ continue
+ report = CheckReportStackIT(
+ metadata=self.metadata(),
+ resource=security_group,
+ )
+ report.status = "PASS"
+ report.status_extended = f"Security group {security_group.name} does not allow unrestricted RDP access."
+ report.resource_id = security_group.id
+ report.resource_name = security_group.name
+ report.location = security_group.region
+
+ for rule in security_group.rules:
+ if (
+ rule.is_ingress()
+ and rule.is_tcp()
+ and rule.is_unrestricted()
+ and rule.includes_port(3389)
+ ):
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Security group {security_group.name} allows unrestricted RDP access (port 3389) "
+ f"from {rule.get_ip_range_display()} via rule {rule.get_rule_display_name()}."
+ )
+ break
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json
new file mode 100644
index 0000000000..df18970ddf
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "stackit",
+ "CheckID": "iaas_security_group_ssh_unrestricted",
+ "CheckTitle": "IaaS security groups do not allow unrestricted SSH access",
+ "CheckType": [],
+ "ServiceName": "iaas",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "Security groups should not allow unrestricted SSH access (port `22`) from the public internet (`0.0.0.0/0` or `::/0`). Unrestricted SSH access increases the attack surface and can lead to **unauthorized access**, **brute force attacks**, and potential system compromise.",
+ "Risk": "Allowing unrestricted SSH access from the internet exposes servers to potential **unauthorized access attempts**, **brute force attacks**, and security breaches. Attackers can scan for and exploit weak credentials or vulnerabilities.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.stackit.cloud/products/network/core-networking/security-groups/",
+ "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows TCP port 22 from 0.0.0.0/0 or ::/0. 3. Delete that rule or edit its source to the specific IP ranges that require SSH access. 4. If administration from anywhere is required, expose SSH only through a bastion host or VPN instead of the public internet. 5. Re-run Prowler to confirm the finding is resolved.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "**Restrict SSH access** to specific IP addresses or ranges that require administrative access. Use **bastion hosts** or **VPN connections** for secure remote access instead of exposing SSH directly to the internet.",
+ "Url": "https://hub.prowler.com/check/iaas_security_group_ssh_unrestricted"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "This check focuses on ingress rules that allow SSH (port 22) from 0.0.0.0/0 or ::/0. Consider implementing additional controls such as multi-factor authentication and regular security audits."
+}
diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py
new file mode 100644
index 0000000000..27b9265c15
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py
@@ -0,0 +1,51 @@
+from prowler.lib.check.models import Check, CheckReportStackIT
+from prowler.providers.stackit.services.iaas.iaas_client import iaas_client
+
+
+class iaas_security_group_ssh_unrestricted(Check):
+ """
+ Check if IaaS security groups allow unrestricted SSH access.
+
+ This check verifies that security groups do not allow SSH (port 22)
+ access from the public internet (0.0.0.0/0 or ::/0).
+ """
+
+ def execute(self):
+ """
+ Execute the check for all security groups in the StackIT project.
+
+ Returns:
+ list: A list of CheckReportStackIT findings
+ """
+ findings = []
+
+ for security_group in iaas_client.security_groups:
+ if not (iaas_client.scan_unused_services or security_group.in_use):
+ continue
+ report = CheckReportStackIT(
+ metadata=self.metadata(),
+ resource=security_group,
+ )
+ report.status = "PASS"
+ report.status_extended = f"Security group {security_group.name} does not allow unrestricted SSH access."
+ report.resource_id = security_group.id
+ report.resource_name = security_group.name
+ report.location = security_group.region
+
+ for rule in security_group.rules:
+ if (
+ rule.is_ingress()
+ and rule.is_tcp()
+ and rule.is_unrestricted()
+ and rule.includes_port(22)
+ ):
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Security group {security_group.name} allows unrestricted SSH access (port 22) "
+ f"from {rule.get_ip_range_display()} via rule {rule.get_rule_display_name()}."
+ )
+ break
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/stackit/services/iaas/iaas_service.py b/prowler/providers/stackit/services/iaas/iaas_service.py
new file mode 100644
index 0000000000..2cea664241
--- /dev/null
+++ b/prowler/providers/stackit/services/iaas/iaas_service.py
@@ -0,0 +1,477 @@
+from typing import Optional
+
+from pydantic.v1 import BaseModel
+
+from prowler.lib.logger import logger
+from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr
+
+
+class IaaSService:
+ """
+ StackIT IaaS Service class to handle security group operations.
+
+ This service uses the StackIT Python SDK to access IaaS resources.
+ Authentication is delegated to the SDK, which signs the RSA challenge
+ in the configured service account key and refreshes access tokens
+ internally for the life of the scan.
+ """
+
+ def __init__(self, provider: StackitProvider):
+ """
+ Initialize the IaaS service.
+
+ Args:
+ provider: The StackIT provider instance
+ """
+ self.provider = provider
+ self.project_id = provider.identity.project_id
+ self.service_account_key_path = provider.session.get("service_account_key_path")
+ self.scan_unused_services = provider.scan_unused_services
+
+ # Generate regional clients (AWS pattern)
+ self.regional_clients = provider.generate_regional_clients("iaas")
+ self.audited_regions = provider.identity.audited_regions
+
+ # Initialize security groups list
+ self.security_groups: list[SecurityGroup] = []
+
+ # Initialize server NICs list and used security group IDs
+ self.server_nics: list = []
+ self.in_use_sg_ids: set[str] = set()
+
+ # Fetch resources from all regions
+ self._fetch_all_regions()
+ self._log_skipped_security_groups()
+
+ def _log_skipped_security_groups(self):
+ """Explain an empty report when every security group is skipped.
+
+ Following the same convention as the rest of Prowler, security group
+ checks only evaluate groups that are in use (attached to a network
+ interface) unless ``--scan-unused-services`` is set. When a project
+ has security groups but none are attached, every check returns no
+ finding, which looks like "nothing was scanned". Emit an explicit
+ hint so the empty report is not mistaken for a failure.
+ """
+ if (
+ not self.scan_unused_services
+ and self.security_groups
+ and not any(sg.in_use for sg in self.security_groups)
+ ):
+ logger.info(
+ f"{len(self.security_groups)} StackIT security group(s) were "
+ f"found but none are attached to a network interface, so all "
+ f"of them are skipped and no finding is produced. Re-run with "
+ f"--scan-unused-services to audit security groups that are "
+ f"not currently in use."
+ )
+
+ def _fetch_all_regions(self):
+ """Fetch resources from all audited regions.
+
+ A project is not necessarily provisioned in every StackIT region. A
+ region where the project does not exist answers the IaaS endpoints
+ with HTTP 404 (``resource not found: project``). That is expected, so
+ the region is skipped and the scan continues with the remaining
+ regions instead of aborting (which previously left every check
+ failing to load and produced an empty, misleading report).
+
+ Credential and permission failures (401/403) still propagate via
+ ``handle_api_error`` so a misconfigured account fails loudly.
+ """
+ for region, client in self.regional_clients.items():
+ try:
+ self._list_server_nics(client, region)
+ self._list_security_groups(client, region)
+ except Exception as error:
+ if getattr(error, "status", None) == 404:
+ logger.info(
+ f"StackIT project {self.project_id} has no IaaS "
+ f"presence in region {region} (404 resource not "
+ f"found); skipping this region."
+ )
+ continue
+ raise
+
+ @staticmethod
+ def _extract_items(response, endpoint_name: str) -> list:
+ """Extract the items list from a StackIT SDK response.
+
+ Handles three response shapes safely:
+ - SDK model exposing an ``items`` attribute (not the ``dict.items`` method)
+ - Raw ``dict`` with an ``"items"`` key
+ - Plain ``list``
+
+ ``isinstance(response, dict)`` is checked first because ``dict`` has an
+ ``items`` *method*; ``hasattr(response, "items")`` is otherwise True for
+ plain dicts and silently returns the bound method.
+ """
+ if isinstance(response, dict):
+ return response.get("items", [])
+ if isinstance(response, list):
+ return response
+ items_attr = getattr(response, "items", None)
+ if items_attr is not None and not callable(items_attr):
+ return items_attr
+ logger.warning(
+ f"Unexpected response type from {endpoint_name}: {type(response)}"
+ )
+ return []
+
+ def _handle_api_call(self, api_function, *args, **kwargs):
+ """
+ Centralized API call handler with authentication error detection.
+
+ Args:
+ api_function: The API function to call
+ *args: Positional arguments to pass to the API function
+ **kwargs: Keyword arguments to pass to the API function
+
+ Returns:
+ The API response
+
+ Raises:
+ StackITInvalidTokenError: If authentication fails (401)
+ """
+ try:
+ # Suppress StackIT SDK stderr messages during API calls
+ with suppress_stderr():
+ return api_function(*args, **kwargs)
+ except Exception as e:
+ # Use centralized error handler from provider
+ self.provider.handle_api_error(e)
+ raise
+
+ def _list_security_groups(self, client, region: str):
+ """
+ List all security groups in the StackIT project and fetch their rules.
+
+ This method populates the self.security_groups list with SecurityGroup
+ objects containing information about each security group and its rules.
+ """
+ if not client:
+ logger.warning(
+ f"Cannot list security groups in {region}: StackIT IaaS client not available"
+ )
+ return
+
+ # Call the list security groups API with centralized error handling
+ response = self._handle_api_call(
+ client.list_security_groups, project_id=self.project_id, region=region
+ )
+
+ # Extract security groups from response
+ security_groups_list = self._extract_items(response, "list_security_groups")
+
+ # Process each security group
+ for sg_data in security_groups_list:
+ try:
+ # Extract security group information
+ if hasattr(sg_data, "id"):
+ sg_id = sg_data.id
+ sg_name = getattr(sg_data, "name", sg_id)
+ elif isinstance(sg_data, dict):
+ sg_id = sg_data.get("id", "")
+ sg_name = sg_data.get("name", sg_id)
+ else:
+ logger.warning(
+ f"Unexpected security group data type: {type(sg_data)}"
+ )
+ continue
+
+ except Exception as e:
+ logger.error(f"Error processing security group: {e}")
+ continue
+
+ # Get security group rules after local parsing succeeds so API errors
+ # from the rules endpoint propagate instead of being downgraded.
+ rules = self._list_security_group_rules(client, region, sg_id)
+
+ security_group = SecurityGroup(
+ id=sg_id,
+ name=sg_name,
+ project_id=self.project_id,
+ region=region,
+ rules=rules,
+ # in_use_sg_ids is normalized to str; the SDK returns the NIC
+ # security group references as uuid.UUID while the security
+ # group id is a str, so compare on the string form.
+ in_use=str(sg_id) in self.in_use_sg_ids,
+ )
+ self.security_groups.append(security_group)
+
+ logger.info(
+ f"Successfully listed {len(security_groups_list)} security groups in {region}"
+ )
+
+ def _list_security_group_rules(
+ self, client, region: str, security_group_id: str
+ ) -> list["SecurityGroupRule"]:
+ """
+ List all rules for a specific security group.
+
+ Args:
+ client: The StackIT IaaS client
+ region: The region of the security group
+ security_group_id: The ID of the security group
+
+ Returns:
+ list: List of SecurityGroupRule objects
+ """
+ rules = []
+ # Get security group rules via SDK
+ response = self._handle_api_call(
+ client.list_security_group_rules,
+ project_id=self.project_id,
+ region=region,
+ security_group_id=security_group_id,
+ )
+
+ # Extract rules from response
+ rules_list = self._extract_items(response, "list_security_group_rules")
+
+ # Process each rule
+ for rule_data in rules_list:
+ try:
+ if hasattr(rule_data, "id"):
+ # Extract protocol name from Protocol object
+ protocol_obj = getattr(rule_data, "protocol", None)
+ protocol_name = None
+ if protocol_obj and hasattr(protocol_obj, "name"):
+ protocol_name = protocol_obj.name
+
+ # Extract port range from PortRange object
+ port_range_obj = getattr(rule_data, "port_range", None)
+ port_min = None
+ port_max = None
+ if port_range_obj:
+ if hasattr(port_range_obj, "min"):
+ port_min = port_range_obj.min
+ if hasattr(port_range_obj, "max"):
+ port_max = port_range_obj.max
+
+ rule = SecurityGroupRule(
+ id=getattr(rule_data, "id", ""),
+ direction=getattr(rule_data, "direction", ""),
+ protocol=protocol_name,
+ ip_range=getattr(rule_data, "ip_range", None),
+ port_range_min=port_min,
+ port_range_max=port_max,
+ description=getattr(rule_data, "description", None),
+ remote_security_group_id=getattr(
+ rule_data, "remote_security_group_id", None
+ ),
+ )
+ elif isinstance(rule_data, dict):
+ # Handle dict response (if API returns dict instead of objects)
+ protocol_data = rule_data.get("protocol")
+ protocol_name = None
+ if isinstance(protocol_data, dict):
+ protocol_name = protocol_data.get("name")
+ elif isinstance(protocol_data, str):
+ protocol_name = protocol_data
+
+ port_range_data = rule_data.get("port_range")
+ port_min = None
+ port_max = None
+ if isinstance(port_range_data, dict):
+ port_min = port_range_data.get("min")
+ port_max = port_range_data.get("max")
+
+ rule = SecurityGroupRule(
+ id=rule_data.get("id", ""),
+ direction=rule_data.get("direction", ""),
+ protocol=protocol_name,
+ ip_range=rule_data.get("ip_range"),
+ port_range_min=port_min,
+ port_range_max=port_max,
+ description=rule_data.get("description"),
+ remote_security_group_id=rule_data.get(
+ "remote_security_group_id"
+ ),
+ )
+ else:
+ continue
+
+ rules.append(rule)
+ logger.debug(
+ f"Parsed rule: id={rule.id}, direction={rule.direction}, "
+ f"protocol={rule.protocol}, ip_range={rule.ip_range}, "
+ f"ports={rule.port_range_min}-{rule.port_range_max}, "
+ f"remote_sg={rule.remote_security_group_id}"
+ )
+
+ except Exception as e:
+ logger.debug(f"Error processing rule: {e}")
+ continue
+
+ return rules
+
+ def _list_server_nics(self, client, region: str):
+ """
+ List all server network interfaces (NICs) in the StackIT project.
+
+ This method fetches all NICs and determines which security groups are
+ actively in use by checking which security groups are attached to any NIC.
+ """
+ if not client:
+ logger.warning(
+ f"Cannot list server NICs in {region}: StackIT IaaS client not available"
+ )
+ return
+
+ # Call the list project NICs API with centralized error handling
+ response = self._handle_api_call(
+ client.list_project_nics, project_id=self.project_id, region=region
+ )
+
+ # Extract NICs from response
+ nics_list = self._extract_items(response, "list_project_nics")
+
+ self.server_nics.extend(nics_list)
+
+ # A security group is "in use" when attached to any NIC
+ used_sg_ids = self._get_used_security_group_ids(nics_list)
+ self.in_use_sg_ids.update(used_sg_ids)
+
+ logger.info(
+ f"Successfully listed {len(nics_list)} NICs in {region}. "
+ f"Found {len(used_sg_ids)} security groups attached to NICs."
+ )
+
+ def _get_used_security_group_ids(self, nics_list) -> set[str]:
+ """
+ Get the set of security group IDs that are actively attached to any NIC.
+
+ Returns:
+ set[str]: Set of security group IDs that are attached to at least one NIC
+ """
+ used_sg_ids = set()
+
+ for nic in nics_list:
+ try:
+ # Extract security groups from NIC. The SDK model exposes them
+ # as ``security_groups``; a raw dict uses the camelCase
+ # ``securityGroups`` key (falling back to snake_case).
+ if hasattr(nic, "security_groups"):
+ sg_list = nic.security_groups
+ elif isinstance(nic, dict):
+ sg_list = nic.get("securityGroups", nic.get("security_groups", []))
+ else:
+ continue
+
+ if sg_list:
+ for sg_id in sg_list:
+ if sg_id:
+ # The SDK returns these references as uuid.UUID
+ # while the security group id is a str; normalize
+ # to str so the membership test in
+ # _list_security_groups matches.
+ used_sg_ids.add(str(sg_id))
+
+ except Exception as e:
+ logger.debug(f"Error extracting security groups from NIC: {e}")
+ continue
+
+ return used_sg_ids
+
+
+class SecurityGroupRule(BaseModel):
+ """
+ Represents a Security Group Rule.
+
+ Attributes:
+ id: The unique identifier of the rule
+ direction: The direction of the rule (ingress/egress)
+ protocol: The protocol (tcp/udp/icmp/all) - can be None for some rules
+ ip_range: The IP range (CIDR notation) - can be None for some rules
+ port_range_min: The minimum port number
+ port_range_max: The maximum port number
+ description: The user-defined description/name of the rule (optional)
+ remote_security_group_id: The ID of a security group to allow traffic from (optional)
+ """
+
+ id: str
+ direction: str
+ protocol: Optional[str] = None
+ ip_range: Optional[str] = None
+ port_range_min: Optional[int] = None
+ port_range_max: Optional[int] = None
+ description: Optional[str] = None
+ remote_security_group_id: Optional[str] = None
+
+ def is_unrestricted(self) -> bool:
+ """Check if the rule allows access from anywhere (0.0.0.0/0, ::/0, or None for unrestricted)."""
+ # If remote_security_group_id is set, the rule only allows traffic from that security group
+ # This is NOT unrestricted access - it's restricted to instances in the same security group
+ if self.remote_security_group_id is not None:
+ return False
+
+ # None means no IP restriction (allows all sources) - this is unrestricted!
+ if self.ip_range is None:
+ return True
+ # Explicit unrestricted ranges
+ return self.ip_range in ["0.0.0.0/0", "::/0"]
+
+ def is_ingress(self) -> bool:
+ """Check if the rule is an ingress rule."""
+ if not self.direction:
+ return False
+ return self.direction.lower() == "ingress"
+
+ def is_tcp(self) -> bool:
+ """Check if the rule is TCP protocol."""
+ # None means all protocols (including TCP) - treat as TCP-applicable
+ if self.protocol is None:
+ return True
+ return self.protocol.lower() in ["tcp", "all"]
+
+ def includes_port(self, port: int) -> bool:
+ """Check if the rule includes a specific port."""
+ if self.port_range_min is None or self.port_range_max is None:
+ # If no port range specified, rule applies to all ports
+ return True
+ return self.port_range_min <= port <= self.port_range_max
+
+ def get_ip_range_display(self) -> str:
+ """
+ Get a user-friendly display string for the IP range.
+
+ Returns:
+ str: Human-readable IP range description
+ """
+ if self.ip_range is None:
+ return "anywhere (0.0.0.0/0, ::/0)"
+ return self.ip_range
+
+ def get_rule_display_name(self) -> str:
+ """
+ Get a user-friendly display name for the rule.
+
+ Returns:
+ str: Rule description if available, otherwise rule ID
+ """
+ if self.description:
+ return f"{self.description} ({self.id})"
+ return f"{self.id}"
+
+
+class SecurityGroup(BaseModel):
+ """
+ Represents a StackIT IaaS Security Group.
+
+ Attributes:
+ id: The unique identifier of the security group
+ name: The name of the security group
+ project_id: The StackIT project ID containing the security group
+ region: The region where the security group is located
+ rules: List of security group rules
+ in_use: Whether the security group is actively attached to any resources
+ """
+
+ id: str
+ name: str
+ project_id: str
+ region: str
+ rules: list[SecurityGroupRule] = []
+ in_use: bool = False
diff --git a/prowler/providers/stackit/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py
new file mode 100644
index 0000000000..417555ef1d
--- /dev/null
+++ b/prowler/providers/stackit/stackit_provider.py
@@ -0,0 +1,617 @@
+import contextlib
+import io
+import os
+import pathlib
+from typing import Optional
+from uuid import UUID
+
+from colorama import Style
+
+# The StackIT SDK is a hard dependency of the provider (declared in
+# pyproject.toml). Import it at module level, like every other Prowler
+# provider, so a missing SDK fails immediately when the provider module is
+# imported and is reported by Provider.init_global_provider as a critical
+# error and a non-zero exit, instead of being swallowed later by the check
+# loader and surfacing as a misleading empty report.
+from stackit.core.configuration import Configuration
+from stackit.iaas import DefaultApi as IaasDefaultApi
+from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi
+
+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 open_file, parse_json_file, print_boxes
+from prowler.providers.common.models import Audit_Metadata, Connection
+from prowler.providers.common.provider import Provider
+from prowler.providers.stackit.exceptions.exceptions import (
+ StackITAPIError,
+ StackITInvalidProjectIdError,
+ StackITInvalidTokenError,
+ StackITNonExistentTokenError,
+ StackITSetUpIdentityError,
+ StackITSetUpSessionError,
+)
+from prowler.providers.stackit.lib.mutelist.mutelist import StackITMutelist
+from prowler.providers.stackit.models import StackITIdentityInfo
+
+STACKIT_REGIONS_JSON_FILE = "stackit_regions_by_service.json"
+
+
+@contextlib.contextmanager
+def suppress_stderr():
+ with contextlib.redirect_stderr(io.StringIO()):
+ yield
+
+
+class StackitProvider(Provider):
+ """
+ StackIT Provider class to handle the StackIT provider
+
+ Attributes:
+ - _type: str -> The type of the provider, which is set to "stackit".
+ - _project_id: str -> The StackIT project ID to audit.
+ - _service_account_key_path: str -> Path to a StackIT service account key
+ JSON file. The SDK mints and refreshes access tokens internally.
+ - _service_account_key: str -> Inline JSON content of a StackIT service
+ account key, for secret-manager driven deployments that do not write
+ the key to disk. Takes precedence over the key path when both are set.
+ - _identity: StackITIdentityInfo -> The identity information for the StackIT provider.
+ - _audit_config: dict -> The audit configuration for the StackIT provider.
+ - _mutelist: StackITMutelist -> The mutelist object associated with the StackIT provider.
+ - audit_metadata: Audit_Metadata -> The audit metadata for the StackIT provider.
+
+ Methods:
+ - __init__: Initializes the StackIT provider.
+ - type: Returns the type of the StackIT provider.
+ - identity: Returns the identity of the StackIT provider (ex: project_id).
+ - session: Returns the session/configuration for API calls.
+ - audit_config: Returns the audit configuration for the StackIT provider.
+ - fixer_config: Returns the fixer configuration.
+ - mutelist: Returns the mutelist object associated with the StackIT provider.
+ - validate_arguments: Validates the StackIT provider arguments (key path, project_id).
+ - print_credentials: Prints the StackIT credentials information (ex: project_id).
+ - setup_session: Set up the StackIT session with the specified authentication method.
+ - test_connection: Tests the provider connection.
+ """
+
+ _type: str = "stackit"
+ _project_id: Optional[str]
+ _service_account_key_path: Optional[str]
+ _service_account_key: Optional[str]
+ _session: Optional[dict]
+ _identity: StackITIdentityInfo
+ _audit_config: dict
+ _mutelist: StackITMutelist
+ _scan_unused_services: bool = False
+ audit_metadata: Audit_Metadata
+
+ def __init__(
+ self,
+ project_id: str = None,
+ service_account_key_path: str = None,
+ service_account_key: str = None,
+ regions: set = None,
+ scan_unused_services: bool = False,
+ config_path: str = None,
+ fixer_config: dict = None,
+ mutelist_path: str = None,
+ mutelist_content: dict = None,
+ ):
+ """
+ Initializes the StackIT provider.
+
+ Args:
+ - project_id: The StackIT project ID to audit.
+ - service_account_key_path: Path to a StackIT service account key
+ JSON file. The SDK mints and refreshes access tokens internally
+ from this key. Read from ``STACKIT_SERVICE_ACCOUNT_KEY_PATH``
+ when not provided.
+ - service_account_key: Inline JSON content of a StackIT service
+ account key, intended for CI/CD where the secret is fetched
+ from a secret manager and not persisted to disk. Read from
+ ``STACKIT_SERVICE_ACCOUNT_KEY`` when not provided. Takes
+ precedence over ``service_account_key_path`` when both are set.
+ - regions: The list of regions to audit.
+ - config_path: The path to the configuration file.
+ - fixer_config: The fixer configuration.
+ - mutelist_path: The path to the mutelist file.
+ - mutelist_content: The mutelist content.
+ """
+ logger.info("Initializing StackIT Provider...")
+
+ # 1) Store argument values
+ self._project_id = project_id or os.getenv("STACKIT_PROJECT_ID")
+ self._service_account_key_path = service_account_key_path or os.getenv(
+ "STACKIT_SERVICE_ACCOUNT_KEY_PATH"
+ )
+ self._service_account_key = service_account_key or os.getenv(
+ "STACKIT_SERVICE_ACCOUNT_KEY"
+ )
+ self._audited_regions = regions if regions else self.get_regions()
+ self._scan_unused_services = scan_unused_services
+
+ # 2) Validate credentials format (following Azure's validation pattern)
+ try:
+ self.validate_arguments(
+ self._project_id,
+ self._service_account_key_path,
+ self._service_account_key,
+ )
+ except StackITNonExistentTokenError:
+ logger.critical(
+ "StackIT service account credentials are required. Provide the "
+ "key file path via --stackit-service-account-key-path / "
+ "STACKIT_SERVICE_ACCOUNT_KEY_PATH, or the key content via "
+ "--stackit-service-account-key / STACKIT_SERVICE_ACCOUNT_KEY."
+ )
+ raise
+ except StackITInvalidProjectIdError:
+ logger.critical(
+ "StackIT project ID must be a valid UUID. Provide it via --stackit-project-id or STACKIT_PROJECT_ID environment variable."
+ )
+ raise
+
+ # 3) Load audit_config, fixer_config, mutelist
+ self._fixer_config = fixer_config if fixer_config else {}
+ if not config_path:
+ config_path = default_config_file_path
+ self._audit_config = load_and_validate_config_file(self._type, config_path)
+
+ if mutelist_content:
+ self._mutelist = StackITMutelist(mutelist_content=mutelist_content)
+ else:
+ if not mutelist_path:
+ mutelist_path = get_default_mute_file_path(self._type)
+ self._mutelist = StackITMutelist(mutelist_path=mutelist_path)
+
+ # 4) Initialize session configuration
+ self._session = None
+ try:
+ self.setup_session()
+ except Exception as e:
+ logger.critical(f"Error setting up StackIT session: {e}")
+ raise StackITSetUpSessionError(
+ original_exception=e,
+ message=f"Failed to set up StackIT session: {str(e)}",
+ )
+
+ # 5) Create StackITIdentityInfo object and fetch project name
+ try:
+ project_name = self._get_project_name()
+ self._identity = StackITIdentityInfo(
+ project_id=self._project_id,
+ project_name=project_name,
+ audited_regions=self._audited_regions,
+ )
+ except StackITInvalidTokenError:
+ # Re-raise authentication errors without wrapping to avoid verbose output
+ raise
+ except Exception as e:
+ logger.critical(f"Error setting up StackIT identity: {e}")
+ raise StackITSetUpIdentityError(
+ original_exception=e,
+ message=f"Failed to set up StackIT identity: {str(e)}",
+ )
+
+ # 6) Register as global provider
+ Provider.set_global_provider(self)
+
+ @staticmethod
+ def read_stackit_regions_file() -> dict:
+ """Read the STACKIT regions JSON file."""
+ actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
+ with open_file(f"{actual_directory}/{STACKIT_REGIONS_JSON_FILE}") as f:
+ return parse_json_file(f)
+
+ @staticmethod
+ def get_regions() -> set:
+ """Get all available STACKIT regions from the JSON file."""
+ regions = set()
+ data = StackitProvider.read_stackit_regions_file()
+ for service in data["services"].values():
+ regions.update(service["regions"])
+ return regions
+
+ @staticmethod
+ def get_available_service_regions(service: str, audited_regions: set = None) -> set:
+ """Get available regions for a specific service, filtered by audited_regions."""
+ data = StackitProvider.read_stackit_regions_file()
+ json_regions = set(data["services"].get(service, {}).get("regions", []))
+ if audited_regions:
+ return json_regions.intersection(audited_regions)
+ return json_regions
+
+ def generate_regional_clients(self, service: str = "iaas") -> dict:
+ """Generate regional API clients for the given service.
+
+ Returns dict: {"eu01": DefaultApi_client, "eu02": DefaultApi_client}
+ """
+ regional_clients = {}
+ service_regions = self.get_available_service_regions(
+ service, self._audited_regions
+ )
+
+ for region in service_regions:
+ with suppress_stderr():
+ config = self._build_sdk_configuration(
+ self._service_account_key_path,
+ self._service_account_key,
+ )
+ client = IaasDefaultApi(config)
+ client.region = region # Attach region attribute
+ regional_clients[region] = client
+
+ return regional_clients
+
+ @staticmethod
+ def _build_sdk_configuration(
+ service_account_key_path: str, service_account_key: str = None
+ ):
+ """Build a ``stackit.core.configuration.Configuration`` from the
+ configured service account credentials.
+
+ Prefer the inlined key content over the path so secret-manager
+ deployments (where the path may also be set as a default) work
+ without writing the secret to disk. In both cases the SDK signs
+ the RSA challenge and refreshes access tokens internally for the
+ life of the scan.
+
+ Kept as a static helper so ``test_connection`` (which has no provider
+ instance) can reuse it.
+ """
+ if service_account_key:
+ return Configuration(service_account_key=service_account_key)
+ return Configuration(service_account_key_path=service_account_key_path)
+
+ @property
+ def type(self) -> str:
+ """
+ Returns the type of the provider ("stackit").
+ """
+ return self._type
+
+ @property
+ def identity(self) -> StackITIdentityInfo:
+ """
+ Returns the StackITIdentityInfo object, which contains project_id, etc.
+ """
+ return self._identity
+
+ @property
+ def session(self) -> dict:
+ """
+ Returns the session configuration for StackIT API calls.
+ This includes the API token and project ID needed for SDK initialization.
+ """
+ return self._session
+
+ @property
+ def audit_config(self) -> dict:
+ """
+ Returns the audit configuration loaded from file or default settings.
+ """
+ return self._audit_config
+
+ @property
+ def fixer_config(self) -> dict:
+ """
+ Returns any fixer configuration provided to the StackIT provider.
+ """
+ return self._fixer_config
+
+ @property
+ def scan_unused_services(self) -> bool:
+ return self._scan_unused_services
+
+ @property
+ def mutelist(self) -> StackITMutelist:
+ """
+ Returns the StackITMutelist object for handling any muted checks.
+ """
+ return self._mutelist
+
+ @staticmethod
+ def validate_arguments(
+ project_id: str,
+ service_account_key_path: str,
+ service_account_key: str = None,
+ ) -> None:
+ """
+ Validates StackIT static arguments format before use.
+
+ Either the service account key file path or the inline key content
+ must be supplied, and the project ID is always required and must be
+ a valid UUID. This mirrors Azure's pattern of failing fast on input
+ format issues before making any API calls.
+
+ Args:
+ project_id: The StackIT project ID (must be valid UUID format)
+ service_account_key_path: Path to a service account key JSON file
+ (optional when ``service_account_key`` is provided)
+ service_account_key: Inline JSON content of a service account key
+ (optional when ``service_account_key_path`` is provided)
+
+ Raises:
+ StackITNonExistentTokenError: If both ``service_account_key_path``
+ and ``service_account_key`` are missing or empty
+ StackITInvalidProjectIdError: If ``project_id`` is missing or not a
+ valid UUID
+ """
+ has_path = bool(service_account_key_path and service_account_key_path.strip())
+ has_key = bool(service_account_key and service_account_key.strip())
+ if not has_path and not has_key:
+ raise StackITNonExistentTokenError(
+ message=(
+ "StackIT service account credentials are required: provide "
+ "the key file path (--stackit-service-account-key-path or "
+ "STACKIT_SERVICE_ACCOUNT_KEY_PATH) or the inline key content "
+ "(--stackit-service-account-key or STACKIT_SERVICE_ACCOUNT_KEY)"
+ )
+ )
+
+ # Validate project_id is not empty
+ if not project_id or not project_id.strip():
+ raise StackITInvalidProjectIdError(
+ message="StackIT project ID is required for auditing"
+ )
+
+ # Validate project_id is a valid UUID format
+ # StackIT uses UUIDs for project IDs, similar to Azure subscription IDs
+ try:
+ UUID(project_id)
+ except ValueError as e:
+ raise StackITInvalidProjectIdError(
+ original_exception=e,
+ message=f"StackIT project ID must be a valid UUID format, got: {project_id}",
+ )
+
+ @property
+ def auth_method(self) -> str:
+ """Auth method label used for findings and credentials box.
+
+ StackIT authenticates with a service account key; the SDK signs
+ the RSA challenge and refreshes access tokens internally.
+ """
+ return "service_account_key"
+
+ def print_credentials(self) -> None:
+ """
+ Prints the StackIT credentials in a simple box format.
+ """
+ # Build credential lines
+ lines = []
+ if self._identity.project_name:
+ lines.append(f" Project Name: {self._identity.project_name}")
+ lines.append(f" Project ID: {self._project_id}")
+ if self._service_account_key:
+ lines.append(" Service Account Key: ***REDACTED*** (inline)")
+ else:
+ lines.append(f" Service Account Key: {self._service_account_key_path}")
+ lines.append(" Auth Method: service account key (auto-refresh)")
+
+ report_lines = ["\n".join(lines)]
+
+ report_title = (
+ f"{Style.BRIGHT}Using the StackIT credentials below:{Style.RESET_ALL}"
+ )
+ print_boxes(report_lines, report_title)
+
+ def setup_session(self) -> None:
+ """
+ Set up the StackIT session configuration.
+
+ This creates a session dictionary containing the credentials
+ used by service clients to build SDK ``Configuration`` objects.
+ """
+ try:
+ self._session = {
+ "project_id": self._project_id,
+ "service_account_key_path": self._service_account_key_path,
+ "service_account_key": self._service_account_key,
+ }
+ logger.info("StackIT session configuration set up successfully.")
+ except Exception as e:
+ logger.critical(f"Error in setup_session: {e}")
+ raise e
+
+ def _get_project_name(self) -> str:
+ """
+ Fetch the project name from the StackIT Resource Manager API.
+
+ The project name is cosmetic (shown in the credentials box and in
+ the ``account_name`` field of findings); a missing or unauthorized
+ Resource Manager endpoint must not abort an otherwise valid IaaS
+ scan. A service account can legitimately hold IaaS roles on a
+ project without holding Resource Manager roles on it.
+
+ Failure semantics:
+ - HTTP 401 -> hard failure via :class:`StackITInvalidTokenError`
+ (the credentials cannot mint a usable token).
+ - HTTP 403 (or any other non-401 error) -> warning, returns
+ an empty project name; the scan continues and per-service
+ ``handle_api_error`` will abort later if the IaaS endpoints
+ are also forbidden.
+
+ Returns:
+ str: The project name, or empty string if unavailable.
+
+ Raises:
+ StackITInvalidTokenError: If the credentials are rejected with a
+ 401 response when contacting Resource Manager.
+ """
+ try:
+ with suppress_stderr():
+ config = self._build_sdk_configuration(
+ self._service_account_key_path,
+ self._service_account_key,
+ )
+ client = ResourceManagerDefaultApi(config)
+
+ # Fetch project details - validates that the credentials
+ # can mint a token; permission to read this specific
+ # project is checked but optional.
+ response = client.get_project(id=self._project_id)
+
+ # Extract project name from response
+ if hasattr(response, "name"):
+ project_name = response.name
+ elif isinstance(response, dict):
+ project_name = response.get("name", "")
+ else:
+ project_name = ""
+
+ logger.info(f"Successfully retrieved project name: {project_name}")
+ return project_name
+
+ except Exception as e:
+ status = getattr(e, "status", None)
+ if status == 401:
+ # Bad credentials are a hard failure even at the cosmetic
+ # Resource Manager lookup; keep the same behaviour as
+ # ``handle_api_error`` so the user sees the same message.
+ logger.critical(
+ "StackIT service account key was rejected by Resource "
+ "Manager (401). Verify the key file is current and has "
+ "not been revoked in the StackIT portal."
+ )
+ raise StackITInvalidTokenError(
+ file="stackit_provider.py",
+ original_exception=None,
+ message="StackIT service account key was rejected (401)",
+ )
+ if status == 403:
+ logger.warning(
+ f"StackIT service account lacks the Resource Manager "
+ f"role on project {self._project_id} (403). The project "
+ f"name will not be displayed in reports, but IaaS "
+ f"checks will still run if the service account has the "
+ f"relevant IaaS role."
+ )
+ return ""
+ logger.warning(
+ f"Unable to fetch project name from StackIT API: {e}. "
+ f"Project name will not be displayed in reports."
+ )
+ return ""
+
+ @staticmethod
+ def handle_api_error(exception: Exception) -> None:
+ """
+ Centralized handler for StackIT API errors across all services.
+
+ Detects credential and permission errors (HTTP 401 and 403) and raises
+ ``StackITInvalidTokenError`` so the scan aborts instead of continuing
+ with partial data. All other exceptions are re-raised unchanged so
+ callers can decide how to handle them (e.g. per-resource ``continue``).
+
+ Args:
+ exception: The exception caught from a StackIT API call
+
+ Raises:
+ StackITInvalidTokenError: If the error is a 401 Unauthorized or
+ a 403 Forbidden response
+ Exception: Re-raises the original exception otherwise
+ """
+ status = getattr(exception, "status", None)
+ if status == 401:
+ logger.critical(
+ "StackIT service account key was rejected. Verify the key "
+ "file referenced by STACKIT_SERVICE_ACCOUNT_KEY_PATH is the "
+ "current one and has not been revoked in the StackIT portal."
+ )
+ raise StackITInvalidTokenError(
+ file="stackit_provider.py",
+ original_exception=None, # Don't include verbose HTTP details
+ message="StackIT service account key was rejected (401)",
+ )
+ if status == 403:
+ logger.critical(
+ "StackIT service account lacks the required permissions on this project. "
+ "Ensure the service account has the necessary IAM roles."
+ )
+ raise StackITInvalidTokenError(
+ file="stackit_provider.py",
+ original_exception=None, # Don't include verbose HTTP details
+ message="Service account lacks required permissions on this project",
+ )
+ # Re-raise other exceptions unchanged
+ raise exception
+
+ @staticmethod
+ def test_connection(
+ project_id: str = None,
+ service_account_key_path: str = None,
+ service_account_key: str = None,
+ raise_on_exception: bool = True,
+ ) -> Connection:
+ """
+ Test connection to StackIT by validating credentials.
+
+ This method validates the service account credentials and project ID
+ by making a Resource Manager ``get_project`` call. Pass either the
+ key file path or the inline key content; the SDK signs the RSA
+ challenge and mints a short-lived access token internally.
+
+ Args:
+ project_id (str): StackIT project ID
+ service_account_key_path (str): Path to a StackIT service account
+ key JSON file (optional when ``service_account_key`` is given)
+ service_account_key (str): Inline JSON content of a StackIT
+ service account key (optional when
+ ``service_account_key_path`` is given)
+ raise_on_exception (bool): If True, raise the caught exception;
+ if False, return Connection(error=exception).
+
+ Returns:
+ Connection:
+ Connection(is_connected=True) if success,
+ otherwise Connection(error=Exception or custom error).
+ """
+ try:
+ StackitProvider.validate_arguments(
+ project_id, service_account_key_path, service_account_key
+ )
+
+ with suppress_stderr():
+ config = StackitProvider._build_sdk_configuration(
+ service_account_key_path,
+ service_account_key,
+ )
+ client = ResourceManagerDefaultApi(config)
+ client.get_project(id=project_id)
+
+ logger.info(
+ "StackIT test_connection: Successfully connected using StackIT Resource Manager."
+ )
+ return Connection(is_connected=True)
+ except (StackITNonExistentTokenError, StackITInvalidProjectIdError) as error:
+ logger.error(f"StackIT test_connection error: {error}")
+ if raise_on_exception:
+ raise error
+ return Connection(error=error)
+ except Exception as test_error:
+ try:
+ StackitProvider.handle_api_error(test_error)
+ except StackITInvalidTokenError as auth_error:
+ if raise_on_exception:
+ raise auth_error
+ return Connection(error=auth_error)
+ except Exception as api_error:
+ error_msg = (
+ "Failed to connect to StackIT using Resource Manager: "
+ f"{str(api_error)}"
+ )
+ logger.error(error_msg)
+ connection_error = StackITAPIError(
+ original_exception=api_error, message=error_msg
+ )
+ if raise_on_exception:
+ raise connection_error
+ return Connection(error=connection_error)
+
+ if raise_on_exception:
+ raise test_error
+ return Connection(error=test_error)
diff --git a/prowler/providers/stackit/stackit_regions_by_service.json b/prowler/providers/stackit/stackit_regions_by_service.json
new file mode 100644
index 0000000000..d9c8cdb9be
--- /dev/null
+++ b/prowler/providers/stackit/stackit_regions_by_service.json
@@ -0,0 +1,10 @@
+{
+ "services": {
+ "iaas": {
+ "regions": [
+ "eu01",
+ "eu02"
+ ]
+ }
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
index 9c41081213..61f0ee5fc9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -93,6 +93,9 @@ dependencies = [
"schema==0.7.5",
"shodan==1.31.0",
"slack-sdk==3.39.0",
+ "stackit-core==0.2.0",
+ "stackit-iaas==1.4.0",
+ "stackit-resourcemanager==0.8.0",
"tabulate==0.9.0",
"tzlocal==5.3.1",
"uuid6==2024.7.10",
diff --git a/tests/config/config_test.py b/tests/config/config_test.py
index 1540ef6d5b..1af6e32df2 100644
--- a/tests/config/config_test.py
+++ b/tests/config/config_test.py
@@ -6,6 +6,7 @@ from unittest import mock
from requests import Response
from prowler.config.config import (
+ Provider,
check_current_version,
get_available_compliance_frameworks,
load_and_validate_config_file,
@@ -17,6 +18,12 @@ MOCK_OLD_PROWLER_VERSION = "0.0.0"
MOCK_PROWLER_MASTER_VERSION = "3.4.0"
+def test_provider_enum_includes_stackit_for_global_discovery():
+ providers = [provider.value for provider in Provider]
+
+ assert "stackit" in providers
+
+
def mock_prowler_get_latest_release(_, **_kwargs):
"""Mock requests.get() to get the Prowler latest release"""
response = Response()
diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py
index d667be9e66..cd602a3a61 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,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,dashboard,iac,image,llm} ..."
def mock_get_available_providers():
@@ -38,6 +38,7 @@ def mock_get_available_providers():
"llm",
"cloudflare",
"openstack",
+ "stackit",
]
diff --git a/tests/lib/cli/redact_test.py b/tests/lib/cli/redact_test.py
index 5de4cc8fd7..c7ff65a765 100644
--- a/tests/lib/cli/redact_test.py
+++ b/tests/lib/cli/redact_test.py
@@ -160,6 +160,7 @@ class TestGetSensitiveArguments:
assert "--atlas-private-key" in result
assert "--nhn-password" in result
assert "--os-password" in result
+ assert "--stackit-service-account-key" in result
def test_does_not_include_non_sensitive_flags(self):
"""Verify non-sensitive flags are not in the set."""
@@ -168,3 +169,4 @@ class TestGetSensitiveArguments:
assert "--region" not in result
assert "--profile" not in result
assert "--output-formats" not in result
+ assert "--stackit-service-account-key-path" not in result
diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py
index fb75a1f705..0f1cb14e25 100644
--- a/tests/lib/outputs/finding_test.py
+++ b/tests/lib/outputs/finding_test.py
@@ -1507,3 +1507,106 @@ class TestFinding:
with pytest.raises(KeyError):
Finding.transform_api_finding(dummy_finding, provider)
+
+ @patch(
+ "prowler.lib.outputs.finding.get_check_compliance",
+ new=mock_get_check_compliance,
+ )
+ def test_generate_output_stackit(self):
+ provider = MagicMock()
+ provider.type = "stackit"
+ provider.auth_method = "service_account_key"
+ provider.identity.project_id = "test-project-id"
+ provider.identity.project_name = "test-project-name"
+
+ check_output = MagicMock()
+ check_output.resource_id = "test_resource_id"
+ check_output.resource_name = "test_resource_name"
+ check_output.resource_details = ""
+ check_output.location = "eu01"
+ check_output.status = Status.PASS
+ check_output.status_extended = "mock_status_extended"
+ check_output.muted = False
+ check_output.check_metadata = mock_check_metadata(provider="stackit")
+ check_output.resource = {}
+ check_output.compliance = {}
+
+ output_options = MagicMock()
+ output_options.unix_timestamp = True
+
+ finding_output = Finding.generate_output(provider, check_output, output_options)
+
+ assert isinstance(finding_output, Finding)
+ assert finding_output.auth_method == "service_account_key"
+ assert finding_output.account_uid == "test-project-id"
+ assert finding_output.account_name == "test-project-name"
+ assert finding_output.resource_name == "test_resource_name"
+ assert finding_output.resource_uid == "test_resource_id"
+ assert finding_output.region == "eu01"
+ assert finding_output.status == Status.PASS
+ assert finding_output.muted is False
+
+ def test_transform_api_finding_stackit(self):
+ provider = MagicMock()
+ provider.type = "stackit"
+ provider.auth_method = "service_account_key"
+ provider.identity.project_id = "stackit-project-id"
+ provider.identity.project_name = "stackit-project-name"
+ dummy_finding = DummyAPIFinding()
+ dummy_finding.inserted_at = 1234567890
+ dummy_finding.scan = DummyScan(provider=provider)
+ dummy_finding.uid = "finding-uid-stackit"
+ dummy_finding.status = "PASS"
+ dummy_finding.status_extended = "StackIT check extended"
+ check_metadata = {
+ "provider": "stackit",
+ "checkid": "service_stackit_check_001",
+ "checktitle": "Test StackIT Check",
+ "checktype": [],
+ "servicename": "service",
+ "subservicename": "",
+ "severity": "high",
+ "resourcetype": "StackITResourceType",
+ "description": "StackIT check description",
+ "risk": "High risk",
+ "relatedurl": "",
+ "remediation": {
+ "code": {
+ "nativeiac": "",
+ "terraform": "",
+ "cli": "",
+ "other": "",
+ },
+ "recommendation": {
+ "text": "Fix it",
+ "url": "https://hub.prowler.com/check/service_stackit_check_001",
+ },
+ },
+ "resourceidtemplate": "template",
+ "categories": ["encryption"],
+ "dependson": [],
+ "relatedto": [],
+ "notes": "StackIT notes",
+ }
+ dummy_finding.check_metadata = check_metadata
+ dummy_finding.raw_result = {}
+ dummy_finding.resource_name = "stackit-resource-name"
+ dummy_finding.resource_id = "stackit-resource-uid"
+ dummy_finding.location = "eu01"
+ dummy_finding.project_id = "stackit-project-id"
+ resource = DummyResource(
+ uid="stackit-resource-uid",
+ name="stackit-resource-name",
+ resource_arn="",
+ region="eu01",
+ tags=[],
+ )
+ dummy_finding.resources = DummyResources(resource)
+ dummy_finding.muted = False
+ finding_obj = Finding.transform_api_finding(dummy_finding, provider)
+ assert finding_obj.auth_method == "service_account_key"
+ assert finding_obj.account_uid == "stackit-project-id"
+ assert finding_obj.account_name == "stackit-project-name"
+ assert finding_obj.resource_name == "stackit-resource-name"
+ assert finding_obj.resource_uid == "stackit-resource-uid"
+ assert finding_obj.region == "eu01"
diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py
index c8d4ac199d..536e40f808 100644
--- a/tests/lib/outputs/html/html_test.py
+++ b/tests/lib/outputs/html/html_test.py
@@ -962,6 +962,43 @@ class TestHTML:
assert summary == image_list_html_assessment_summary
+ def test_stackit_get_assessment_summary(self):
+ """Test StackIT HTML assessment summary shows the project ID."""
+ findings = [generate_finding_output()]
+ output = HTML(findings)
+
+ provider = MagicMock()
+ provider.type = "stackit"
+ provider.identity.project_id = "f033ea6d-8697-40eb-a60e-acfa9128480d"
+ provider.identity.project_name = "ProwlerDev"
+ provider.identity.audited_regions = {"eu01", "eu02"}
+
+ summary = output.get_assessment_summary(provider)
+
+ assert "StackIT Assessment Summary" in summary
+ assert "StackIT Credentials" in summary
+ assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary
+ assert "Project Name: ProwlerDev" in summary
+ assert "Regions: eu01, eu02" in summary
+ assert "Authentication Type: Service Account Key" in summary
+
+ def test_stackit_get_assessment_summary_without_project_name(self):
+ """Project ID is always shown; the Project Name line is omitted when
+ the service account cannot read it from Resource Manager."""
+ findings = [generate_finding_output()]
+ output = HTML(findings)
+
+ provider = MagicMock()
+ provider.type = "stackit"
+ provider.identity.project_id = "f033ea6d-8697-40eb-a60e-acfa9128480d"
+ provider.identity.project_name = ""
+ provider.identity.audited_regions = {"eu01"}
+
+ summary = output.get_assessment_summary(provider)
+
+ assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary
+ assert "Project Name:" not in summary
+
def test_process_markdown_bold_text(self):
"""Test that **text** is converted to text"""
test_text = "This is **bold text** and this is **also bold**"
diff --git a/tests/lib/outputs/outputs_test.py b/tests/lib/outputs/outputs_test.py
index 4aeccf1104..87901bd290 100644
--- a/tests/lib/outputs/outputs_test.py
+++ b/tests/lib/outputs/outputs_test.py
@@ -1256,3 +1256,43 @@ class TestReport:
f"\t{Fore.YELLOW}INFO{Style.RESET_ALL} There are no resources"
)
mocked_print.assert_called() # Verifying that print was called
+
+ def test_report_with_stackit_provider_pass(self):
+ finding = MagicMock()
+ finding.status = "PASS"
+ finding.muted = False
+ finding.location = "eu01"
+ finding.check_metadata.Provider = "stackit"
+ finding.status_extended = "Security group has no unrestricted SSH access"
+
+ output_options = MagicMock()
+ output_options.verbose = True
+ output_options.status = ["PASS", "FAIL"]
+ output_options.fixer = False
+
+ provider = MagicMock()
+ provider.type = "stackit"
+
+ with mock.patch("builtins.print") as mocked_print:
+ report([finding], provider, output_options)
+ mocked_print.assert_called()
+
+ def test_report_with_stackit_provider_fail(self):
+ finding = MagicMock()
+ finding.status = "FAIL"
+ finding.muted = False
+ finding.location = "eu01"
+ finding.check_metadata.Provider = "stackit"
+ finding.status_extended = "Security group allows unrestricted SSH access"
+
+ output_options = MagicMock()
+ output_options.verbose = True
+ output_options.status = ["PASS", "FAIL"]
+ output_options.fixer = False
+
+ provider = MagicMock()
+ provider.type = "stackit"
+
+ with mock.patch("builtins.print") as mocked_print:
+ report([finding], provider, output_options)
+ mocked_print.assert_called()
diff --git a/tests/lib/outputs/summary_table_test.py b/tests/lib/outputs/summary_table_test.py
index c1e5322c33..0c842225cc 100644
--- a/tests/lib/outputs/summary_table_test.py
+++ b/tests/lib/outputs/summary_table_test.py
@@ -40,3 +40,65 @@ class TestDisplaySummaryTable:
assert "Subscriptions scanned:" in captured.out
assert "Duplicate Subscription (subscription-id-1)" in captured.out
assert "Duplicate Subscription (subscription-id-2)" in captured.out
+
+ def test_stackit_summary_with_project_name(self, capsys):
+ provider = SimpleNamespace(
+ type="stackit",
+ identity=SimpleNamespace(
+ project_id="test-project-id",
+ project_name="my-prod-env",
+ ),
+ )
+ output_options = SimpleNamespace(
+ output_directory="out",
+ output_filename="report",
+ output_modes=[],
+ )
+ findings = [
+ SimpleNamespace(
+ status="PASS",
+ muted=False,
+ check_metadata=SimpleNamespace(
+ ServiceName="iaas",
+ Provider="stackit",
+ Severity="high",
+ ),
+ )
+ ]
+
+ display_summary_table(findings, provider, output_options)
+
+ captured = capsys.readouterr()
+ assert "Project" in captured.out
+ assert "my-prod-env" in captured.out
+
+ def test_stackit_summary_with_project_id_only(self, capsys):
+ provider = SimpleNamespace(
+ type="stackit",
+ identity=SimpleNamespace(
+ project_id="test-project-id",
+ project_name=None,
+ ),
+ )
+ output_options = SimpleNamespace(
+ output_directory="out",
+ output_filename="report",
+ output_modes=[],
+ )
+ findings = [
+ SimpleNamespace(
+ status="PASS",
+ muted=False,
+ check_metadata=SimpleNamespace(
+ ServiceName="iaas",
+ Provider="stackit",
+ Severity="high",
+ ),
+ )
+ ]
+
+ display_summary_table(findings, provider, output_options)
+
+ captured = capsys.readouterr()
+ assert "Project ID" in captured.out
+ assert "test-project-id" in captured.out
diff --git a/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py b/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py
index bfd898cb26..5661ca1724 100644
--- a/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py
+++ b/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py
@@ -107,7 +107,9 @@ class Test_entra_managed_device_required_for_authentication:
type=None,
interval=SignInFrequencyInterval.TIME_BASED,
),
- application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False),
+ application_enforced_restrictions=ApplicationEnforcedRestrictions(
+ is_enabled=False
+ ),
),
state=ConditionalAccessPolicyState.DISABLED,
)
@@ -186,7 +188,9 @@ class Test_entra_managed_device_required_for_authentication:
type=None,
interval=SignInFrequencyInterval.TIME_BASED,
),
- application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False),
+ application_enforced_restrictions=ApplicationEnforcedRestrictions(
+ is_enabled=False
+ ),
),
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
)
@@ -269,7 +273,9 @@ class Test_entra_managed_device_required_for_authentication:
type=None,
interval=SignInFrequencyInterval.TIME_BASED,
),
- application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False),
+ application_enforced_restrictions=ApplicationEnforcedRestrictions(
+ is_enabled=False
+ ),
),
state=ConditionalAccessPolicyState.ENABLED,
)
diff --git a/tests/providers/stackit/lib/arguments/stackit_arguments_test.py b/tests/providers/stackit/lib/arguments/stackit_arguments_test.py
new file mode 100644
index 0000000000..36635e14d2
--- /dev/null
+++ b/tests/providers/stackit/lib/arguments/stackit_arguments_test.py
@@ -0,0 +1,68 @@
+import argparse
+
+import pytest
+
+from prowler.providers.stackit.lib.arguments.arguments import init_parser
+
+
+@pytest.fixture
+def parser():
+ parser = argparse.ArgumentParser()
+ parser.common_providers_parser = argparse.ArgumentParser(add_help=False)
+ parser.subparsers = parser.add_subparsers(dest="provider")
+ init_parser(parser)
+ return parser
+
+
+class TestStackITArguments:
+ def test_project_id_argument_is_registered(self, parser):
+ args = parser.parse_args(
+ [
+ "stackit",
+ "--stackit-project-id",
+ "12345678-1234-1234-1234-123456789abc",
+ ]
+ )
+
+ assert args.stackit_project_id == "12345678-1234-1234-1234-123456789abc"
+
+ def test_api_token_argument_is_not_registered(self, parser):
+ """Tokens were removed in favour of the service account key file."""
+ with pytest.raises(SystemExit):
+ parser.parse_args(["stackit", "--stackit-api-token", "secret-token"])
+
+ def test_service_account_key_path_argument_is_registered(self, parser):
+ args = parser.parse_args(
+ [
+ "stackit",
+ "--stackit-service-account-key-path",
+ "/tmp/sa-key.json",
+ ]
+ )
+ assert args.stackit_service_account_key_path == "/tmp/sa-key.json"
+
+ def test_service_account_key_path_defaults_to_none(self, parser):
+ args = parser.parse_args(["stackit"])
+ assert args.stackit_service_account_key_path is None
+
+ def test_service_account_key_argument_is_registered(self, parser):
+ args = parser.parse_args(
+ [
+ "stackit",
+ "--stackit-service-account-key",
+ '{"keyId": "abc"}',
+ ]
+ )
+ assert args.stackit_service_account_key == '{"keyId": "abc"}'
+
+ def test_service_account_key_defaults_to_none(self, parser):
+ args = parser.parse_args(["stackit"])
+ assert args.stackit_service_account_key is None
+
+ def test_scan_unused_services_defaults_to_false(self, parser):
+ args = parser.parse_args(["stackit"])
+ assert args.scan_unused_services is False
+
+ def test_scan_unused_services_flag_sets_true(self, parser):
+ args = parser.parse_args(["stackit", "--scan-unused-services"])
+ assert args.scan_unused_services is True
diff --git a/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py b/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py
new file mode 100644
index 0000000000..72e7770943
--- /dev/null
+++ b/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py
@@ -0,0 +1,33 @@
+from unittest.mock import MagicMock
+
+from prowler.providers.stackit.lib.mutelist.mutelist import StackITMutelist
+
+
+class TestStackITMutelist:
+ def test_is_finding_muted_uses_project_id_as_account(self):
+ mutelist_content = {
+ "Accounts": {
+ "project_1": {
+ "Checks": {
+ "check_test": {
+ "Regions": ["*"],
+ "Resources": ["test_resource"],
+ }
+ }
+ }
+ }
+ }
+
+ mutelist = StackITMutelist(mutelist_content=mutelist_content)
+
+ finding = MagicMock()
+ finding.project_id = "project_1"
+ finding.resource_id = "resource_1"
+ finding.check_metadata = MagicMock()
+ finding.check_metadata.CheckID = "check_test"
+ finding.status = "FAIL"
+ finding.resource_name = "test_resource"
+ finding.location = "eu01"
+ finding.resource_tags = {}
+
+ assert mutelist.is_finding_muted(finding=finding)
diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py
new file mode 100644
index 0000000000..0db0c9d6d7
--- /dev/null
+++ b/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py
@@ -0,0 +1,504 @@
+from unittest import mock
+from uuid import uuid4
+
+from prowler.providers.stackit.services.iaas.iaas_service import (
+ SecurityGroup,
+ SecurityGroupRule,
+)
+from tests.providers.stackit.stackit_fixtures import (
+ STACKIT_PROJECT_ID,
+ set_mocked_stackit_provider,
+)
+
+
+class Test_iaas_security_group_all_traffic_unrestricted:
+ def setup_method(self):
+ mock.MagicMock.scan_unused_services = False
+
+ def test_no_security_groups(self):
+ """Test with no security groups - should return empty results."""
+ iaas_client = mock.MagicMock
+ iaas_client.security_groups = []
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_security_group_not_in_use(self):
+ """Test security group not in use - should be skipped."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=None,
+ port_range_max=None,
+ )
+ ],
+ in_use=False,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_security_group_no_rules(self):
+ """Test security group with no rules - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Security group {security_group_name} does not allow unrestricted access to all traffic."
+ )
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+ assert result[0].location == "eu01"
+
+ def test_security_group_all_ports_none_range(self):
+ """Test security group with None port range (all ports) - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=None,
+ port_range_max=None,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ "allows unrestricted access to all traffic" in result[0].status_extended
+ )
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+ assert result[0].location == "eu01"
+
+ def test_security_group_all_ports_full_range(self):
+ """Test security group with full port range 1-65535 - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=1,
+ port_range_max=65535,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ "allows unrestricted access to all traffic" in result[0].status_extended
+ )
+
+ def test_security_group_all_ports_zero_range(self):
+ """Test security group with port range 0-65535 - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=0,
+ port_range_max=65535,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+
+ def test_security_group_limited_port_range(self):
+ """Test security group with limited port range - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=80,
+ port_range_max=443,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ "does not allow unrestricted access to all traffic"
+ in result[0].status_extended
+ )
+
+ def test_security_group_restricted_ip_all_ports(self):
+ """Test security group with all ports but restricted IP - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="10.0.0.0/8", # Restricted IP
+ port_range_min=None,
+ port_range_max=None,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_security_group_egress_all_ports(self):
+ """Test security group with egress rule for all ports - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="egress", # Egress, not ingress
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=None,
+ port_range_max=None,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_security_group_multiple_unrestricted_rules(self):
+ """Test security group with multiple unrestricted rules - should FAIL with all listed."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=None,
+ port_range_max=None,
+ ),
+ SecurityGroupRule(
+ id="rule-2",
+ direction="ingress",
+ protocol="udp",
+ ip_range="0.0.0.0/0",
+ port_range_min=1,
+ port_range_max=65535,
+ ),
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import (
+ iaas_security_group_all_traffic_unrestricted,
+ )
+
+ check = iaas_security_group_all_traffic_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "rule-1" in result[0].status_extended
+ assert "rule-2" in result[0].status_extended
diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py
new file mode 100644
index 0000000000..83a279efac
--- /dev/null
+++ b/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py
@@ -0,0 +1,503 @@
+from unittest import mock
+from uuid import uuid4
+
+from prowler.providers.stackit.services.iaas.iaas_service import (
+ SecurityGroup,
+ SecurityGroupRule,
+)
+from tests.providers.stackit.stackit_fixtures import (
+ STACKIT_PROJECT_ID,
+ set_mocked_stackit_provider,
+)
+
+
+class Test_iaas_security_group_database_unrestricted:
+ def setup_method(self):
+ mock.MagicMock.scan_unused_services = False
+
+ def test_no_security_groups(self):
+ """Test with no security groups - should return empty results."""
+ iaas_client = mock.MagicMock
+ iaas_client.security_groups = []
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_security_group_not_in_use(self):
+ """Test security group not in use - should be skipped."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=3306,
+ port_range_max=3306,
+ )
+ ],
+ in_use=False,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_security_group_no_rules(self):
+ """Test security group with no rules - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Security group {security_group_name} does not allow unrestricted database access."
+ )
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+ assert result[0].location == "eu01"
+
+ def test_security_group_mysql_unrestricted(self):
+ """Test security group with MySQL port 3306 unrestricted - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=3306,
+ port_range_max=3306,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "MySQL (port 3306)" in result[0].status_extended
+ assert "allows unrestricted database access" in result[0].status_extended
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+ assert result[0].location == "eu01"
+
+ def test_security_group_postgresql_unrestricted(self):
+ """Test security group with PostgreSQL port 5432 unrestricted - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=5432,
+ port_range_max=5432,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "PostgreSQL (port 5432)" in result[0].status_extended
+
+ def test_security_group_mongodb_unrestricted(self):
+ """Test security group with MongoDB port 27017 unrestricted - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=27017,
+ port_range_max=27017,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "MongoDB (port 27017)" in result[0].status_extended
+
+ def test_security_group_multiple_databases_unrestricted(self):
+ """Test security group with multiple database ports unrestricted - should FAIL with all listed."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=3306,
+ port_range_max=3306,
+ ),
+ SecurityGroupRule(
+ id="rule-2",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=5432,
+ port_range_max=5432,
+ ),
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "MySQL (port 3306)" in result[0].status_extended
+ assert "PostgreSQL (port 5432)" in result[0].status_extended
+
+ def test_security_group_database_port_range(self):
+ """Test security group with port range including database port - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=3000,
+ port_range_max=4000,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "MySQL (port 3306)" in result[0].status_extended
+
+ def test_security_group_database_restricted_ip(self):
+ """Test security group with database port restricted to specific IP - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="10.0.0.0/8",
+ port_range_min=3306,
+ port_range_max=3306,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ "does not allow unrestricted database access"
+ in result[0].status_extended
+ )
+
+ def test_security_group_non_database_port(self):
+ """Test security group with unrestricted access but non-database port - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=80,
+ port_range_max=80,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import (
+ iaas_security_group_database_unrestricted,
+ )
+
+ check = iaas_security_group_database_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py
new file mode 100644
index 0000000000..65e21d4883
--- /dev/null
+++ b/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py
@@ -0,0 +1,389 @@
+from unittest import mock
+from uuid import uuid4
+
+from prowler.providers.stackit.services.iaas.iaas_service import (
+ SecurityGroup,
+ SecurityGroupRule,
+)
+from tests.providers.stackit.stackit_fixtures import (
+ STACKIT_PROJECT_ID,
+ set_mocked_stackit_provider,
+)
+
+
+class Test_iaas_security_group_rdp_unrestricted:
+ def setup_method(self):
+ mock.MagicMock.scan_unused_services = False
+
+ def test_no_security_groups(self):
+ """Test with no security groups - should return empty results."""
+ iaas_client = mock.MagicMock
+ iaas_client.security_groups = []
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import (
+ iaas_security_group_rdp_unrestricted,
+ )
+
+ check = iaas_security_group_rdp_unrestricted()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_security_group_not_in_use(self):
+ """Test security group not in use - should be skipped."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=3389,
+ port_range_max=3389,
+ )
+ ],
+ in_use=False,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import (
+ iaas_security_group_rdp_unrestricted,
+ )
+
+ check = iaas_security_group_rdp_unrestricted()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_security_group_no_rules(self):
+ """Test security group with no rules - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import (
+ iaas_security_group_rdp_unrestricted,
+ )
+
+ check = iaas_security_group_rdp_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Security group {security_group_name} does not allow unrestricted RDP access."
+ )
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+ assert result[0].location == "eu01"
+
+ def test_security_group_rdp_unrestricted_exact_port(self):
+ """Test security group with RDP port 3389 unrestricted - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=3389,
+ port_range_max=3389,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import (
+ iaas_security_group_rdp_unrestricted,
+ )
+
+ check = iaas_security_group_rdp_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "allows unrestricted RDP access" in result[0].status_extended
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+ assert result[0].location == "eu01"
+
+ def test_security_group_rdp_unrestricted_port_range(self):
+ """Test security group with port range including RDP - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=3380,
+ port_range_max=3400,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import (
+ iaas_security_group_rdp_unrestricted,
+ )
+
+ check = iaas_security_group_rdp_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "allows unrestricted RDP access" in result[0].status_extended
+
+ def test_security_group_rdp_restricted_ip(self):
+ """Test security group with RDP restricted to specific IP - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="10.0.0.0/8",
+ port_range_min=3389,
+ port_range_max=3389,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import (
+ iaas_security_group_rdp_unrestricted,
+ )
+
+ check = iaas_security_group_rdp_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "does not allow unrestricted RDP access" in result[0].status_extended
+
+ def test_security_group_different_port(self):
+ """Test security group with unrestricted access but different port - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=80,
+ port_range_max=80,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import (
+ iaas_security_group_rdp_unrestricted,
+ )
+
+ check = iaas_security_group_rdp_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_security_group_none_ip_range(self):
+ """Test security group with None ip_range (unrestricted) - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range=None,
+ port_range_min=3389,
+ port_range_max=3389,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import (
+ iaas_security_group_rdp_unrestricted,
+ )
+
+ check = iaas_security_group_rdp_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py
new file mode 100644
index 0000000000..cabb167e78
--- /dev/null
+++ b/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py
@@ -0,0 +1,589 @@
+from unittest import mock
+from uuid import uuid4
+
+from prowler.providers.stackit.services.iaas.iaas_service import (
+ SecurityGroup,
+ SecurityGroupRule,
+)
+from tests.providers.stackit.stackit_fixtures import (
+ STACKIT_PROJECT_ID,
+ set_mocked_stackit_provider,
+)
+
+
+class Test_iaas_security_group_ssh_unrestricted:
+ def setup_method(self):
+ mock.MagicMock.scan_unused_services = False
+
+ def test_no_security_groups(self):
+ """Test with no security groups - should return empty results."""
+ iaas_client = mock.MagicMock
+ iaas_client.security_groups = []
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_security_group_not_in_use(self):
+ """Test security group not in use - should be skipped."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ ],
+ in_use=False, # Not in use - should be skipped
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_security_group_no_rules(self):
+ """Test security group with no rules - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Security group {security_group_name} does not allow unrestricted SSH access."
+ )
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+ assert result[0].location == "eu01"
+
+ def test_security_group_ssh_unrestricted_exact_port(self):
+ """Test security group with SSH port 22 unrestricted - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "allows unrestricted SSH access" in result[0].status_extended
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+ assert result[0].location == "eu01"
+
+ def test_security_group_ssh_unrestricted_port_range(self):
+ """Test security group with port range including SSH - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=20,
+ port_range_max=25,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "allows unrestricted SSH access" in result[0].status_extended
+ assert result[0].resource_id == security_group_id
+ assert result[0].resource_name == security_group_name
+
+ def test_security_group_ssh_restricted_ip(self):
+ """Test security group with SSH restricted to specific IP - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="10.0.0.0/8",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "does not allow unrestricted SSH access" in result[0].status_extended
+
+ def test_security_group_different_port(self):
+ """Test security group with unrestricted access but different port - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=80,
+ port_range_max=80,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_security_group_egress_rule(self):
+ """Test security group with egress rule - should PASS."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="egress", # Egress, not ingress
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_security_group_none_protocol(self):
+ """Test security group with None protocol (all protocols) - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol=None, # None means all protocols (includes TCP)
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+
+ def test_security_group_none_ip_range(self):
+ """Test security group with None ip_range (unrestricted) - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range=None, # None means unrestricted access
+ port_range_min=22,
+ port_range_max=22,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+
+ def test_security_group_none_port_range(self):
+ """Test security group with None port range (all ports) - should FAIL."""
+ iaas_client = mock.MagicMock
+ security_group_name = "test-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=None, # None means all ports
+ port_range_max=None,
+ )
+ ],
+ in_use=True,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+
+ def test_security_group_not_in_use_with_scan_unused_services(self):
+ """Unused SG with unrestricted SSH must FAIL when scan_unused_services=True."""
+ iaas_client = mock.MagicMock
+ iaas_client.scan_unused_services = True
+ security_group_name = "unused-security-group"
+ security_group_id = str(uuid4())
+
+ iaas_client.security_groups = [
+ SecurityGroup(
+ id=security_group_id,
+ name=security_group_name,
+ project_id=STACKIT_PROJECT_ID,
+ region="eu01",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ ],
+ in_use=False,
+ )
+ ]
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_stackit_provider(scan_unused_services=True),
+ ),
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService",
+ new=iaas_client,
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.stackit.services.iaas.iaas_client.iaas_client",
+ new=service_client,
+ ),
+ ):
+ from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import (
+ iaas_security_group_ssh_unrestricted,
+ )
+
+ check = iaas_security_group_ssh_unrestricted()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "allows unrestricted SSH access" in result[0].status_extended
diff --git a/tests/providers/stackit/services/iaas/iaas_service_test.py b/tests/providers/stackit/services/iaas/iaas_service_test.py
new file mode 100644
index 0000000000..79ea0c33de
--- /dev/null
+++ b/tests/providers/stackit/services/iaas/iaas_service_test.py
@@ -0,0 +1,515 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from prowler.providers.stackit.exceptions.exceptions import StackITInvalidTokenError
+from prowler.providers.stackit.services.iaas.iaas_service import (
+ IaaSService,
+ SecurityGroupRule,
+)
+from tests.providers.stackit.stackit_fixtures import (
+ STACKIT_PROJECT_ID,
+ set_mocked_stackit_provider,
+)
+
+
+def mock_iaas_fetch_all_regions(_):
+ """Mock the _fetch_all_regions method to avoid real API calls."""
+
+
+@patch(
+ "prowler.providers.stackit.services.iaas.iaas_service.IaaSService._fetch_all_regions",
+ new=mock_iaas_fetch_all_regions,
+)
+class Test_IaaS_Service:
+ def test_service_initialization(self):
+ """Test that the IaaS service initializes correctly."""
+ iaas_service = IaaSService(set_mocked_stackit_provider())
+
+ assert iaas_service.project_id == STACKIT_PROJECT_ID
+ assert iaas_service.service_account_key_path is not None
+ assert isinstance(iaas_service.security_groups, list)
+ assert isinstance(iaas_service.server_nics, list)
+ assert isinstance(iaas_service.in_use_sg_ids, set)
+ assert iaas_service.scan_unused_services is False
+
+ def test_service_project_id(self):
+ """Test that the service correctly extracts project_id from provider."""
+ iaas_service = IaaSService(set_mocked_stackit_provider())
+ assert iaas_service.project_id == STACKIT_PROJECT_ID
+
+ def test_service_service_account_key_path(self):
+ """Test that the service correctly extracts the SA key path from provider."""
+ custom_path = "/tmp/custom-sa.json"
+ provider = set_mocked_stackit_provider(service_account_key_path=custom_path)
+ iaas_service = IaaSService(provider)
+ assert iaas_service.service_account_key_path == custom_path
+
+ def test_security_groups_list_structure(self):
+ """Test that security_groups is properly initialized as a list."""
+ iaas_service = IaaSService(set_mocked_stackit_provider())
+ assert hasattr(iaas_service, "security_groups")
+ assert isinstance(iaas_service.security_groups, list)
+
+ def test_in_use_sg_ids_set_structure(self):
+ """Test that in_use_sg_ids is properly initialized as a set."""
+ iaas_service = IaaSService(set_mocked_stackit_provider())
+ assert hasattr(iaas_service, "in_use_sg_ids")
+ assert isinstance(iaas_service.in_use_sg_ids, set)
+
+ @pytest.mark.parametrize(
+ "method_name,client_method_name",
+ [
+ ("_list_server_nics", "list_project_nics"),
+ ("_list_security_groups", "list_security_groups"),
+ ("_list_security_group_rules", "list_security_group_rules"),
+ ],
+ )
+ def test_list_methods_propagate_api_errors(self, method_name, client_method_name):
+ """API/auth failures must fail the scan instead of returning empty data."""
+ iaas_service = IaaSService(set_mocked_stackit_provider())
+ client = MagicMock()
+ getattr(client, client_method_name).side_effect = StackITInvalidTokenError(
+ message="Invalid token"
+ )
+
+ with pytest.raises(StackITInvalidTokenError):
+ if method_name == "_list_security_group_rules":
+ getattr(iaas_service, method_name)(client, "eu01", "sg-1")
+ else:
+ getattr(iaas_service, method_name)(client, "eu01")
+
+ def test_security_group_parsing_errors_are_skipped_locally(self):
+ """Malformed resources are skipped while valid resources are retained."""
+
+ class MalformedSecurityGroup:
+ @property
+ def id(self):
+ raise ValueError("malformed security group")
+
+ iaas_service = IaaSService(set_mocked_stackit_provider())
+ client = MagicMock()
+ client.list_security_groups.return_value = [
+ MalformedSecurityGroup(),
+ {"id": "sg-1", "name": "valid-sg"},
+ ]
+ client.list_security_group_rules.return_value = []
+
+ iaas_service._list_security_groups(client, "eu01")
+
+ assert len(iaas_service.security_groups) == 1
+ assert iaas_service.security_groups[0].id == "sg-1"
+
+ def test_in_use_considers_all_nics_not_only_public(self):
+ """A SG attached to any NIC (public or private) counts as in_use."""
+ iaas_service = IaaSService(set_mocked_stackit_provider())
+
+ # NIC without a public IP, but has a security group attached
+ private_nic = {"id": "nic-private", "security_groups": ["sg-private"]}
+ used = iaas_service._get_used_security_group_ids([private_nic])
+
+ assert "sg-private" in used
+
+ def test_in_use_sg_ids_populated_via_list_server_nics(self):
+ """_list_server_nics marks SGs on any NIC as in_use."""
+ iaas_service = IaaSService(set_mocked_stackit_provider())
+ client = MagicMock()
+ client.list_project_nics.return_value = [
+ {"id": "nic-1", "security_groups": ["sg-1"]},
+ {"id": "nic-2", "security_groups": ["sg-2"]},
+ ]
+
+ iaas_service._list_server_nics(client, "eu01")
+
+ assert "sg-1" in iaas_service.in_use_sg_ids
+ assert "sg-2" in iaas_service.in_use_sg_ids
+
+
+# Test SecurityGroupRule helper methods
+class Test_SecurityGroupRule:
+ def test_is_unrestricted_with_none(self):
+ """Test that None ip_range is considered unrestricted."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range=None,
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_unrestricted() is True
+
+ def test_is_unrestricted_with_cidr(self):
+ """Test that 0.0.0.0/0 is considered unrestricted."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_unrestricted() is True
+
+ def test_is_unrestricted_with_ipv6(self):
+ """Test that ::/0 is considered unrestricted."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="::/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_unrestricted() is True
+
+ def test_is_restricted_with_specific_ip(self):
+ """Test that specific IP range is considered restricted."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="10.0.0.0/8",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_unrestricted() is False
+
+ def test_is_ingress_true(self):
+ """Test that ingress direction is properly detected."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_ingress() is True
+
+ def test_is_ingress_false(self):
+ """Test that egress direction returns false for is_ingress."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="egress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_ingress() is False
+
+ def test_is_tcp_with_tcp_protocol(self):
+ """Test that TCP protocol is detected correctly."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_tcp() is True
+
+ def test_is_tcp_with_none_protocol(self):
+ """Test that None protocol is treated as TCP (all protocols)."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol=None,
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_tcp() is True
+
+ def test_is_tcp_with_all_protocol(self):
+ """Test that 'all' protocol is treated as TCP."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="all",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_tcp() is True
+
+ def test_is_tcp_with_udp_protocol(self):
+ """Test that UDP protocol returns false for is_tcp."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="udp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.is_tcp() is False
+
+ def test_includes_port_exact_match(self):
+ """Test that exact port match is detected."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ )
+ assert rule.includes_port(22) is True
+
+ def test_includes_port_in_range(self):
+ """Test that port within range is detected."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=20,
+ port_range_max=25,
+ )
+ assert rule.includes_port(22) is True
+ assert rule.includes_port(20) is True
+ assert rule.includes_port(25) is True
+
+ def test_includes_port_outside_range(self):
+ """Test that port outside range is not detected."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=80,
+ port_range_max=443,
+ )
+ assert rule.includes_port(22) is False
+
+ def test_includes_port_with_none_range(self):
+ """Test that None port range means all ports."""
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ip_range="0.0.0.0/0",
+ port_range_min=None,
+ port_range_max=None,
+ )
+ assert rule.includes_port(22) is True
+ assert rule.includes_port(443) is True
+ assert rule.includes_port(65535) is True
+
+
+class Test_IaaS_Service_Extract_Items:
+ """Cover ``IaaSService._extract_items`` against the three response shapes the
+ StackIT SDK can return. The previous implementation matched ``dict`` via
+ ``hasattr(response, "items")`` and returned the bound method instead of the
+ items field.
+ """
+
+ def test_extract_items_from_sdk_model(self):
+ """SDK models expose ``items`` as a non-callable attribute."""
+ response = MagicMock(spec=["items"])
+ sentinel = [{"id": "sg-1"}, {"id": "sg-2"}]
+ response.items = sentinel
+ assert IaaSService._extract_items(response, "endpoint") is sentinel
+
+ def test_extract_items_from_dict_with_items_key(self):
+ """Dict responses must use the ``items`` key, not ``dict.items()``."""
+ response = {"items": [{"id": "sg-1"}]}
+ assert IaaSService._extract_items(response, "endpoint") == [{"id": "sg-1"}]
+
+ def test_extract_items_from_empty_dict(self):
+ """An empty dict yields an empty list, not the ``dict.items`` method."""
+ assert IaaSService._extract_items({}, "endpoint") == []
+
+ def test_extract_items_from_list(self):
+ """A plain list response is returned as-is."""
+ response = [{"id": "sg-1"}]
+ assert IaaSService._extract_items(response, "endpoint") is response
+
+ def test_extract_items_unknown_shape_returns_empty(self):
+ """Unknown shapes fall back to an empty list and log a warning."""
+ assert IaaSService._extract_items(42, "endpoint") == []
+
+ def test_extract_items_ignores_dict_items_method(self):
+ """Regression: ``dict`` exposes ``items`` as a method; ensure the
+ ``isinstance(dict)`` branch wins and we do not return the bound method.
+ """
+ result = IaaSService._extract_items({"items": ["ok"]}, "endpoint")
+ assert result == ["ok"]
+ assert not callable(result)
+
+
+class Test_IaaS_Service_Used_Security_Group_Ids:
+ """Cover ``_get_used_security_group_ids``. The SDK returns NIC security
+ group references as ``uuid.UUID`` while a security group id is a ``str``;
+ they must be normalized to ``str`` so the in-use membership test matches.
+ """
+
+ def _service(self):
+ return object.__new__(IaaSService)
+
+ def test_uuid_references_are_normalized_to_str(self):
+ from uuid import UUID
+
+ sg_uuid = UUID("2040c1fa-72a6-47bc-a53b-f62075ae6d35")
+ nic = MagicMock(spec=["security_groups"])
+ nic.security_groups = [sg_uuid]
+
+ used = self._service()._get_used_security_group_ids([nic])
+
+ # Stored as the string form so `str(sg.id) in used` matches.
+ assert used == {"2040c1fa-72a6-47bc-a53b-f62075ae6d35"}
+ assert all(isinstance(x, str) for x in used)
+
+ def test_dict_nic_camelcase_security_groups_key(self):
+ nic = {"securityGroups": ["sg-aaaa", "sg-bbbb"]}
+ used = self._service()._get_used_security_group_ids([nic])
+ assert used == {"sg-aaaa", "sg-bbbb"}
+
+ def test_empty_nic_security_groups(self):
+ nic = MagicMock(spec=["security_groups"])
+ nic.security_groups = []
+ assert self._service()._get_used_security_group_ids([nic]) == set()
+
+ def test_in_use_matches_uuid_reference_end_to_end(self):
+ """A security group whose id (str) matches a NIC reference (UUID) must
+ be flagged in_use=True after a full region fetch.
+ """
+ from uuid import UUID
+
+ sg_id = "2040c1fa-72a6-47bc-a53b-f62075ae6d35"
+ client = MagicMock()
+ nic = MagicMock(spec=["security_groups"])
+ nic.security_groups = [UUID(sg_id)]
+ client.list_project_nics.return_value = {"items": [nic]}
+ client.list_security_groups.return_value = {"items": [{"id": sg_id}]}
+ client.list_security_group_rules.return_value = {"items": []}
+
+ from prowler.providers.stackit.stackit_provider import StackitProvider
+
+ service = object.__new__(IaaSService)
+ service.provider = MagicMock()
+ service.provider.handle_api_error = StackitProvider.handle_api_error
+ service.project_id = STACKIT_PROJECT_ID
+ service.scan_unused_services = False
+ service.regional_clients = {"eu01": client}
+ service.security_groups = []
+ service.server_nics = []
+ service.in_use_sg_ids = set()
+
+ service._fetch_all_regions()
+
+ assert len(service.security_groups) == 1
+ assert service.security_groups[0].in_use is True
+
+
+class Test_IaaS_Service_Fetch_All_Regions:
+ """Cover ``_fetch_all_regions`` multi-region behaviour. A project is not
+ provisioned in every StackIT region; the region where it is absent answers
+ with HTTP 404. That must be skipped, not abort the whole scan (which
+ previously left every check failing to load with an empty report).
+ """
+
+ class _NotFound(Exception):
+ status = 404
+
+ class _Forbidden(Exception):
+ status = 403
+
+ def _service(self, regional_clients):
+ from prowler.providers.stackit.stackit_provider import StackitProvider
+
+ service = object.__new__(IaaSService)
+ service.provider = MagicMock()
+ # Reuse the real centralized error handler so 401/403/404 semantics
+ # match production.
+ service.provider.handle_api_error = StackitProvider.handle_api_error
+ service.project_id = STACKIT_PROJECT_ID
+ service.scan_unused_services = True
+ service.regional_clients = regional_clients
+ service.security_groups = []
+ service.server_nics = []
+ service.in_use_sg_ids = set()
+ return service
+
+ def _good_client(self, sg_id="sg-eu01"):
+ client = MagicMock()
+ client.list_project_nics.return_value = {"items": []}
+ client.list_security_groups.return_value = {"items": [{"id": sg_id}]}
+ client.list_security_group_rules.return_value = {"items": []}
+ return client
+
+ def _missing_region_client(self):
+ client = MagicMock()
+ client.list_project_nics.side_effect = self._NotFound()
+ client.list_security_groups.side_effect = self._NotFound()
+ return client
+
+ def test_skips_region_where_project_is_absent(self):
+ service = self._service(
+ {"eu01": self._good_client(), "eu02": self._missing_region_client()}
+ )
+
+ service._fetch_all_regions()
+
+ # eu01 security group is collected; the eu02 404 is skipped silently.
+ assert [sg.id for sg in service.security_groups] == ["sg-eu01"]
+
+ def test_403_still_aborts(self):
+ bad = MagicMock()
+ bad.list_project_nics.side_effect = self._Forbidden()
+ service = self._service({"eu01": bad})
+
+ with pytest.raises(StackITInvalidTokenError):
+ service._fetch_all_regions()
+
+
+class Test_IaaS_Service_Log_Skipped_Security_Groups:
+ """``_log_skipped_security_groups`` should emit a hint only when groups
+ exist, none are in use, and ``scan_unused_services`` is off.
+ """
+
+ def _service(self, security_groups, scan_unused_services):
+ service = object.__new__(IaaSService)
+ service.scan_unused_services = scan_unused_services
+ service.security_groups = security_groups
+ return service
+
+ def _sg(self, in_use):
+ sg = MagicMock()
+ sg.in_use = in_use
+ return sg
+
+ def test_logs_when_all_skipped(self, caplog):
+ import logging
+
+ service = self._service([self._sg(False), self._sg(False)], False)
+ with caplog.at_level(logging.INFO):
+ service._log_skipped_security_groups()
+ assert "scan-unused-services" in caplog.text
+
+ def test_no_log_when_scan_unused_services_enabled(self, caplog):
+ import logging
+
+ service = self._service([self._sg(False)], True)
+ with caplog.at_level(logging.INFO):
+ service._log_skipped_security_groups()
+ assert "scan-unused-services" not in caplog.text
+
+ def test_no_log_when_a_group_is_in_use(self, caplog):
+ import logging
+
+ service = self._service([self._sg(False), self._sg(True)], False)
+ with caplog.at_level(logging.INFO):
+ service._log_skipped_security_groups()
+ assert "scan-unused-services" not in caplog.text
+
+ def test_no_log_when_no_security_groups(self, caplog):
+ import logging
+
+ service = self._service([], False)
+ with caplog.at_level(logging.INFO):
+ service._log_skipped_security_groups()
+ assert "scan-unused-services" not in caplog.text
diff --git a/tests/providers/stackit/stackit_exceptions_test.py b/tests/providers/stackit/stackit_exceptions_test.py
new file mode 100644
index 0000000000..b6577305b0
--- /dev/null
+++ b/tests/providers/stackit/stackit_exceptions_test.py
@@ -0,0 +1,29 @@
+from prowler.providers.stackit.exceptions.exceptions import (
+ StackITBaseException,
+ StackITInvalidTokenError,
+)
+
+
+class Test_StackIT_Exception_Catalog_Immutability:
+ """Regression: ``StackITBaseException.__init__`` previously assigned the
+ per-instance ``message`` override straight onto the class-level
+ ``STACKIT_ERROR_CODES`` dict, leaking the override into every later
+ exception of the same code raised in the same process.
+ """
+
+ def _default_message(self, code: int, class_name: str) -> str:
+ """Read the default message directly from the unmodified catalog."""
+ return StackITBaseException.STACKIT_ERROR_CODES[(code, class_name)]["message"]
+
+ def test_message_override_does_not_mutate_class_catalog(self):
+ default = self._default_message(16002, "StackITInvalidTokenError")
+ StackITInvalidTokenError(message="instance-specific message")
+ assert self._default_message(16002, "StackITInvalidTokenError") == default
+
+ def test_sequential_overrides_do_not_leak(self):
+ """An override on instance A must not affect instance B."""
+ default = self._default_message(16002, "StackITInvalidTokenError")
+ StackITInvalidTokenError(message="A")
+ StackITInvalidTokenError(message="B")
+ second_default = self._default_message(16002, "StackITInvalidTokenError")
+ assert second_default == default
diff --git a/tests/providers/stackit/stackit_fixtures.py b/tests/providers/stackit/stackit_fixtures.py
new file mode 100644
index 0000000000..7312bbc542
--- /dev/null
+++ b/tests/providers/stackit/stackit_fixtures.py
@@ -0,0 +1,54 @@
+from uuid import uuid4
+
+from mock import MagicMock
+
+from prowler.providers.stackit.models import StackITIdentityInfo
+from prowler.providers.stackit.stackit_provider import StackitProvider
+
+# StackIT Test Constants
+STACKIT_PROJECT_ID = str(uuid4())
+STACKIT_SERVICE_ACCOUNT_KEY_PATH = "/tmp/stackit-sa-key.json"
+STACKIT_PROJECT_NAME = "Test Project"
+
+
+def set_mocked_stackit_provider(
+ service_account_key_path: str = STACKIT_SERVICE_ACCOUNT_KEY_PATH,
+ project_id: str = STACKIT_PROJECT_ID,
+ identity: StackITIdentityInfo = None,
+ audit_config: dict = None,
+ fixer_config: dict = None,
+ scan_unused_services: bool = False,
+) -> StackitProvider:
+ """
+ Create a mocked StackIT provider for testing.
+
+ Args:
+ service_account_key_path: Path to the service account key file
+ (default: ``STACKIT_SERVICE_ACCOUNT_KEY_PATH`` constant)
+ project_id: The project ID to use (default: STACKIT_PROJECT_ID)
+ identity: Custom identity info (default: creates new StackITIdentityInfo)
+ audit_config: Audit configuration dict (default: None)
+ fixer_config: Fixer configuration dict (default: None)
+
+ Returns:
+ MagicMock: A mocked StackitProvider instance
+ """
+ if identity is None:
+ identity = StackITIdentityInfo(
+ project_id=project_id,
+ project_name=STACKIT_PROJECT_NAME,
+ )
+
+ provider = MagicMock()
+ provider.type = "stackit"
+ provider.identity = identity
+ provider.session = {
+ "project_id": project_id,
+ "service_account_key_path": service_account_key_path,
+ }
+ provider.audit_config = audit_config if audit_config else {}
+ provider.fixer_config = fixer_config if fixer_config else {}
+ provider.scan_unused_services = scan_unused_services
+ provider.auth_method = "service_account_key"
+
+ return provider
diff --git a/tests/providers/stackit/stackit_metadata_test.py b/tests/providers/stackit/stackit_metadata_test.py
new file mode 100644
index 0000000000..7becf6bea5
--- /dev/null
+++ b/tests/providers/stackit/stackit_metadata_test.py
@@ -0,0 +1,15 @@
+from pathlib import Path
+
+import pytest
+
+from prowler.lib.check.models import CheckMetadata
+
+
+@pytest.mark.parametrize(
+ "metadata_file",
+ sorted(Path("prowler/providers/stackit").glob("services/**/*.metadata.json")),
+)
+def test_stackit_check_metadata_is_valid(metadata_file):
+ metadata = CheckMetadata.parse_file(metadata_file)
+ assert metadata.Provider == "stackit"
+ assert metadata.CheckID == metadata_file.stem.replace(".metadata", "")
diff --git a/tests/providers/stackit/stackit_provider_test.py b/tests/providers/stackit/stackit_provider_test.py
new file mode 100644
index 0000000000..8bcbe17f84
--- /dev/null
+++ b/tests/providers/stackit/stackit_provider_test.py
@@ -0,0 +1,413 @@
+"""Tests for StackIT Provider input validation."""
+
+import types
+from argparse import Namespace
+from unittest.mock import patch
+
+import pytest
+
+import prowler.providers.common.provider as common_provider
+import prowler.providers.stackit.stackit_provider as stackit_provider_module
+from prowler.providers.common.models import Connection
+from prowler.providers.stackit.exceptions.exceptions import (
+ StackITAPIError,
+ StackITInvalidProjectIdError,
+ StackITInvalidTokenError,
+ StackITNonExistentTokenError,
+)
+from prowler.providers.stackit.stackit_provider import StackitProvider
+
+
+class TestStackITProviderValidation:
+ """Test suite for StackIT Provider input validation."""
+
+ KEY_PATH = "/tmp/sa-key.json"
+ VALID_PROJECT_ID = "12345678-1234-1234-1234-123456789abc"
+
+ def test_validate_arguments_valid(self):
+ """Test validation passes with a key path and a valid UUID."""
+ StackitProvider.validate_arguments(self.VALID_PROJECT_ID, self.KEY_PATH)
+
+ def test_validate_arguments_empty_key_path(self):
+ with pytest.raises(StackITNonExistentTokenError) as exc_info:
+ StackitProvider.validate_arguments(self.VALID_PROJECT_ID, "")
+ assert "service account credentials are required" in str(exc_info.value)
+
+ def test_validate_arguments_none_key_path(self):
+ with pytest.raises(StackITNonExistentTokenError) as exc_info:
+ StackitProvider.validate_arguments(self.VALID_PROJECT_ID, None)
+ assert "service account credentials are required" in str(exc_info.value)
+
+ def test_validate_arguments_whitespace_only_key_path(self):
+ with pytest.raises(StackITNonExistentTokenError) as exc_info:
+ StackitProvider.validate_arguments(self.VALID_PROJECT_ID, " ")
+ assert "service account credentials are required" in str(exc_info.value)
+
+ def test_validate_arguments_empty_project_id(self):
+ with pytest.raises(StackITInvalidProjectIdError) as exc_info:
+ StackitProvider.validate_arguments("", self.KEY_PATH)
+ assert "project ID is required" in str(exc_info.value)
+
+ def test_validate_arguments_none_project_id(self):
+ with pytest.raises(StackITInvalidProjectIdError) as exc_info:
+ StackitProvider.validate_arguments(None, self.KEY_PATH)
+ assert "project ID is required" in str(exc_info.value)
+
+ def test_validate_arguments_whitespace_only_project_id(self):
+ with pytest.raises(StackITInvalidProjectIdError) as exc_info:
+ StackitProvider.validate_arguments(" ", self.KEY_PATH)
+ assert "project ID is required" in str(exc_info.value)
+
+ def test_validate_arguments_invalid_uuid_format(self):
+ with pytest.raises(StackITInvalidProjectIdError) as exc_info:
+ StackitProvider.validate_arguments("not-a-valid-uuid", self.KEY_PATH)
+ assert "must be a valid UUID format" in str(exc_info.value)
+ assert "not-a-valid-uuid" in str(exc_info.value)
+
+ def test_validate_arguments_invalid_uuid_too_short(self):
+ with pytest.raises(StackITInvalidProjectIdError) as exc_info:
+ StackitProvider.validate_arguments("1234-5678", self.KEY_PATH)
+ assert "must be a valid UUID format" in str(exc_info.value)
+
+ def test_validate_arguments_uuid_without_hyphens(self):
+ """Python's UUID() accepts compact form."""
+ StackitProvider.validate_arguments(
+ "12345678123412341234123456789abc", self.KEY_PATH
+ )
+
+ def test_validate_arguments_numeric_only_project_id(self):
+ with pytest.raises(StackITInvalidProjectIdError) as exc_info:
+ StackitProvider.validate_arguments("123456789012", self.KEY_PATH)
+ assert "must be a valid UUID format" in str(exc_info.value)
+
+ def test_validate_arguments_uuid_with_uppercase(self):
+ StackitProvider.validate_arguments(
+ "12345678-1234-1234-1234-123456789ABC", self.KEY_PATH
+ )
+
+ def test_validate_arguments_uuid_with_braces(self):
+ StackitProvider.validate_arguments(
+ "{12345678-1234-1234-1234-123456789abc}", self.KEY_PATH
+ )
+
+ def test_validate_arguments_uuid_v4_format(self):
+ StackitProvider.validate_arguments(
+ "550e8400-e29b-41d4-a716-446655440000", self.KEY_PATH
+ )
+
+ def test_validate_arguments_both_invalid(self):
+ """Key path is checked first."""
+ with pytest.raises(StackITNonExistentTokenError):
+ StackitProvider.validate_arguments("not-a-uuid", "")
+
+ def test_validate_arguments_both_none(self):
+ """Key path is checked first."""
+ with pytest.raises(StackITNonExistentTokenError):
+ StackitProvider.validate_arguments(None, None)
+
+ def test_validate_arguments_accepts_inline_key_without_path(self):
+ """Inline key content alone is enough; the path can be omitted."""
+ StackitProvider.validate_arguments(
+ self.VALID_PROJECT_ID, None, '{"keyId": "abc"}'
+ )
+
+ def test_validate_arguments_accepts_inline_key_when_path_is_empty(self):
+ StackitProvider.validate_arguments(
+ self.VALID_PROJECT_ID, "", '{"keyId": "abc"}'
+ )
+
+ def test_validate_arguments_rejects_when_inline_key_is_whitespace(self):
+ with pytest.raises(StackITNonExistentTokenError):
+ StackitProvider.validate_arguments(self.VALID_PROJECT_ID, None, " ")
+
+
+class TestStackITProviderInitialization:
+ def test_init_global_provider_passes_key_path_from_cli(self, monkeypatch):
+ """The service account key path and inline key content from CLI args
+ are forwarded to the provider constructor; tokens are not part of
+ the API anymore."""
+ captured_kwargs = {}
+
+ class FakeStackitProvider:
+ def __init__(self, **kwargs):
+ captured_kwargs.update(kwargs)
+
+ fake_module = types.SimpleNamespace(StackitProvider=FakeStackitProvider)
+ arguments = Namespace(
+ provider="stackit",
+ stackit_project_id="12345678-1234-1234-1234-123456789abc",
+ stackit_service_account_key_path="/tmp/sa-key.json",
+ stackit_service_account_key='{"keyId": "abc"}',
+ stackit_region=None,
+ scan_unused_services=False,
+ config_file="config.yaml",
+ mutelist_file=None,
+ fixer_config="fixer_config.yaml",
+ )
+
+ monkeypatch.setattr(common_provider.Provider, "_global", None)
+
+ with (
+ patch.object(common_provider, "import_module", return_value=fake_module),
+ patch.object(
+ common_provider, "load_and_validate_config_file", return_value={}
+ ),
+ ):
+ common_provider.Provider.init_global_provider(arguments)
+
+ assert "api_token" not in captured_kwargs
+ assert captured_kwargs["project_id"] == arguments.stackit_project_id
+ assert (
+ captured_kwargs["service_account_key_path"]
+ == arguments.stackit_service_account_key_path
+ )
+ assert (
+ captured_kwargs["service_account_key"]
+ == arguments.stackit_service_account_key
+ )
+ assert captured_kwargs["scan_unused_services"] is False
+
+ def test_init_global_provider_passes_scan_unused_services_true(self, monkeypatch):
+ """scan_unused_services=True is forwarded to the provider constructor."""
+ captured_kwargs = {}
+
+ class FakeStackitProvider:
+ def __init__(self, **kwargs):
+ captured_kwargs.update(kwargs)
+
+ fake_module = types.SimpleNamespace(StackitProvider=FakeStackitProvider)
+ arguments = Namespace(
+ provider="stackit",
+ stackit_project_id="12345678-1234-1234-1234-123456789abc",
+ stackit_service_account_key_path="/tmp/sa-key.json",
+ stackit_region=None,
+ scan_unused_services=True,
+ config_file="config.yaml",
+ mutelist_file=None,
+ fixer_config="fixer_config.yaml",
+ )
+
+ monkeypatch.setattr(common_provider.Provider, "_global", None)
+
+ with (
+ patch.object(common_provider, "import_module", return_value=fake_module),
+ patch.object(
+ common_provider, "load_and_validate_config_file", return_value={}
+ ),
+ ):
+ common_provider.Provider.init_global_provider(arguments)
+
+ assert captured_kwargs["scan_unused_services"] is True
+
+
+class TestStackITProviderTestConnection:
+ KEY_PATH = "/tmp/sa-key.json"
+ KEY_CONTENT = '{"keyId": "abc", "publicKey": "..."}'
+ PROJECT_ID = "12345678-1234-1234-1234-123456789abc"
+
+ @pytest.fixture
+ def fake_stackit_resourcemanager(self, monkeypatch):
+ class FakeConfiguration:
+ def __init__(self, service_account_key_path=None, service_account_key=None):
+ self.service_account_key_path = service_account_key_path
+ self.service_account_key = service_account_key
+
+ class FakeDefaultApi:
+ error = None
+ calls = []
+
+ def __init__(self, config):
+ self.config = config
+
+ def get_project(self, id):
+ self.__class__.calls.append(
+ (
+ self.config.service_account_key_path,
+ self.config.service_account_key,
+ id,
+ )
+ )
+ if self.__class__.error:
+ raise self.__class__.error
+ return {"name": "Test Project"}
+
+ # The SDK is imported at module level, so patch the names bound in the
+ # provider module rather than sys.modules.
+ monkeypatch.setattr(stackit_provider_module, "Configuration", FakeConfiguration)
+ monkeypatch.setattr(
+ stackit_provider_module, "ResourceManagerDefaultApi", FakeDefaultApi
+ )
+ return FakeDefaultApi
+
+ def test_connection_success_with_key_path(self, fake_stackit_resourcemanager):
+ connection = StackitProvider.test_connection(
+ project_id=self.PROJECT_ID,
+ service_account_key_path=self.KEY_PATH,
+ )
+
+ assert connection == Connection(is_connected=True)
+ assert fake_stackit_resourcemanager.calls == [
+ (self.KEY_PATH, None, self.PROJECT_ID)
+ ]
+
+ def test_connection_success_with_inline_key(self, fake_stackit_resourcemanager):
+ """The inline key path takes precedence over the file path."""
+ connection = StackitProvider.test_connection(
+ project_id=self.PROJECT_ID,
+ service_account_key=self.KEY_CONTENT,
+ )
+
+ assert connection == Connection(is_connected=True)
+ assert fake_stackit_resourcemanager.calls == [
+ (None, self.KEY_CONTENT, self.PROJECT_ID)
+ ]
+
+ def test_connection_inline_key_overrides_path(self, fake_stackit_resourcemanager):
+ connection = StackitProvider.test_connection(
+ project_id=self.PROJECT_ID,
+ service_account_key_path=self.KEY_PATH,
+ service_account_key=self.KEY_CONTENT,
+ )
+
+ assert connection == Connection(is_connected=True)
+ # When the inline key is present the SDK is configured with it and
+ # the key path is not passed on at all.
+ assert fake_stackit_resourcemanager.calls == [
+ (None, self.KEY_CONTENT, self.PROJECT_ID)
+ ]
+
+ def test_connection_returns_error_when_raise_on_exception_is_false(
+ self, fake_stackit_resourcemanager
+ ):
+ fake_stackit_resourcemanager.error = RuntimeError("denied")
+
+ connection = StackitProvider.test_connection(
+ project_id=self.PROJECT_ID,
+ service_account_key_path=self.KEY_PATH,
+ raise_on_exception=False,
+ )
+
+ assert isinstance(connection.error, StackITAPIError)
+ assert "Failed to connect to StackIT using Resource Manager" in str(
+ connection.error
+ )
+
+ def test_connection_raises_when_raise_on_exception_is_true(
+ self, fake_stackit_resourcemanager
+ ):
+ fake_stackit_resourcemanager.error = RuntimeError("denied")
+
+ with pytest.raises(StackITAPIError):
+ StackitProvider.test_connection(
+ project_id=self.PROJECT_ID,
+ service_account_key_path=self.KEY_PATH,
+ raise_on_exception=True,
+ )
+
+
+class TestStackITProviderGetProjectName:
+ @pytest.fixture
+ def fake_resourcemanager(self, monkeypatch):
+ class FakeConfiguration:
+ def __init__(self, service_account_key_path=None, service_account_key=None):
+ self.service_account_key_path = service_account_key_path
+ self.service_account_key = service_account_key
+
+ class FakeDefaultApi:
+ error = None
+
+ def __init__(self, config):
+ pass
+
+ def get_project(self, id):
+ if self.__class__.error:
+ raise self.__class__.error
+ return {"name": "My Project"}
+
+ # The SDK is imported at module level, so patch the names bound in the
+ # provider module rather than sys.modules.
+ monkeypatch.setattr(stackit_provider_module, "Configuration", FakeConfiguration)
+ monkeypatch.setattr(
+ stackit_provider_module, "ResourceManagerDefaultApi", FakeDefaultApi
+ )
+ return FakeDefaultApi
+
+ def _make_provider(self):
+ provider = object.__new__(StackitProvider)
+ provider._service_account_key_path = "/tmp/sa-key.json"
+ provider._service_account_key = None
+ provider._project_id = "12345678-1234-1234-1234-123456789abc"
+ return provider
+
+ def test_get_project_name_403_returns_empty_string_and_does_not_abort(
+ self, fake_resourcemanager
+ ):
+ """The Resource Manager lookup is cosmetic; a 403 there must NOT
+ abort the scan because the service account can legitimately hold
+ IaaS roles on the project without holding Resource Manager roles.
+ """
+
+ class Http403Error(Exception):
+ status = 403
+
+ fake_resourcemanager.error = Http403Error()
+ provider = self._make_provider()
+
+ # Must not raise; returns an empty project name so the scan can
+ # continue and the IaaS service can decide whether *its* endpoints
+ # are forbidden too.
+ assert provider._get_project_name() == ""
+
+ def test_get_project_name_401_raises_invalid_token_error(
+ self, fake_resourcemanager
+ ):
+ class Http401Error(Exception):
+ status = 401
+
+ fake_resourcemanager.error = Http401Error()
+ provider = self._make_provider()
+
+ with pytest.raises(StackITInvalidTokenError):
+ provider._get_project_name()
+
+ def test_get_project_name_other_error_returns_empty_string(
+ self, fake_resourcemanager
+ ):
+ fake_resourcemanager.error = RuntimeError("network error")
+ provider = self._make_provider()
+
+ result = provider._get_project_name()
+
+ assert result == ""
+
+
+class Test_StackitProvider_Handle_API_Error:
+ """Direct coverage for ``StackitProvider.handle_api_error`` so 401 and 403
+ are treated as credential failures by every service call site, not just
+ ``_get_project_name``.
+ """
+
+ def _http(self, status_code: int) -> Exception:
+ err = Exception(f"http {status_code}")
+ err.status = status_code
+ return err
+
+ def test_401_raises_invalid_token_error(self):
+ with pytest.raises(StackITInvalidTokenError):
+ StackitProvider.handle_api_error(self._http(401))
+
+ def test_403_raises_invalid_token_error(self):
+ with pytest.raises(StackITInvalidTokenError):
+ StackitProvider.handle_api_error(self._http(403))
+
+ def test_other_http_status_is_reraised_unchanged(self):
+ original = self._http(500)
+ with pytest.raises(Exception) as excinfo:
+ StackitProvider.handle_api_error(original)
+ assert excinfo.value is original
+
+ def test_exception_without_status_is_reraised_unchanged(self):
+ original = RuntimeError("network error")
+ with pytest.raises(RuntimeError) as excinfo:
+ StackitProvider.handle_api_error(original)
+ assert excinfo.value is original
diff --git a/uv.lock b/uv.lock
index 80c42e7823..1341671060 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3319,6 +3319,9 @@ dependencies = [
{ name = "schema" },
{ name = "shodan" },
{ name = "slack-sdk" },
+ { name = "stackit-core" },
+ { name = "stackit-iaas" },
+ { name = "stackit-resourcemanager" },
{ name = "tabulate" },
{ name = "tzlocal" },
{ name = "uuid6" },
@@ -3424,6 +3427,9 @@ requires-dist = [
{ name = "schema", specifier = "==0.7.5" },
{ name = "shodan", specifier = "==1.31.0" },
{ name = "slack-sdk", specifier = "==3.39.0" },
+ { name = "stackit-core", specifier = "==0.2.0" },
+ { name = "stackit-iaas", specifier = "==1.4.0" },
+ { name = "stackit-resourcemanager", specifier = "==0.8.0" },
{ name = "tabulate", specifier = "==0.9.0" },
{ name = "tzlocal", specifier = "==5.3.1" },
{ name = "uuid6", specifier = "==2024.7.10" },
@@ -4257,6 +4263,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
+[[package]]
+name = "stackit-core"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "pydantic" },
+ { name = "pyjwt" },
+ { name = "requests" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" },
+]
+
+[[package]]
+name = "stackit-iaas"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "stackit-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" },
+]
+
+[[package]]
+name = "stackit-resourcemanager"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "stackit-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
+]
+
[[package]]
name = "std-uritemplate"
version = "2.0.8"