feat(stackit): add new provider with 4 checks (#9237)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
This commit is contained in:
Johannes Engler
2026-05-28 13:16:38 +02:00
committed by GitHub
parent edbbd86828
commit a2824f7166
68 changed files with 6360 additions and 7 deletions
+48
View File
@@ -541,6 +541,54 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-vercel flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml 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 # Lib
- name: Check if Lib files changed - name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true' if: steps.check-changes.outputs.any_changed == 'true'
+1
View File
@@ -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 | | Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI |
| Okta | 1 | 1 | 0 | 1 | Official | CLI | | Okta | 1 | 1 | 0 | 1 | Official | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | 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 | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
> [!Note] > [!Note]
+730
View File
@@ -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.
+9 -1
View File
@@ -339,6 +339,13 @@
"user-guide/providers/scaleway/authentication" "user-guide/providers/scaleway/authentication"
] ]
}, },
{
"group": "StackIT",
"pages": [
"user-guide/providers/stackit/getting-started-stackit",
"user-guide/providers/stackit/authentication"
]
},
{ {
"group": "Vercel", "group": "Vercel",
"pages": [ "pages": [
@@ -401,7 +408,8 @@
"developer-guide/kubernetes-details", "developer-guide/kubernetes-details",
"developer-guide/m365-details", "developer-guide/m365-details",
"developer-guide/github-details", "developer-guide/github-details",
"developer-guide/llm-details" "developer-guide/llm-details",
"developer-guide/stackit-details"
] ]
}, },
{ {
+1
View File
@@ -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 | | [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 | | [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 | | [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 ### Infrastructure as Code Providers
@@ -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
```
<Note>
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.
</Note>
### 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
```
<Note>
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.
</Note>
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 |
@@ -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.
<Note>
StackIT support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact).
</Note>
## 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
```
<Note>
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.
</Note>
### 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).
+1
View File
@@ -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) - `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) - 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) - `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) - 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) - 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356)
+5
View File
@@ -158,6 +158,7 @@ from prowler.providers.okta.models import OktaOutputOptions
from prowler.providers.openstack.models import OpenStackOutputOptions from prowler.providers.openstack.models import OpenStackOutputOptions
from prowler.providers.oraclecloud.models import OCIOutputOptions from prowler.providers.oraclecloud.models import OCIOutputOptions
from prowler.providers.scaleway.models import ScalewayOutputOptions from prowler.providers.scaleway.models import ScalewayOutputOptions
from prowler.providers.stackit.models import StackITOutputOptions
from prowler.providers.vercel.models import VercelOutputOptions from prowler.providers.vercel.models import VercelOutputOptions
@@ -416,6 +417,10 @@ def prowler():
output_options = OCIOutputOptions( output_options = OCIOutputOptions(
args, bulk_checks_metadata, global_provider.identity args, bulk_checks_metadata, global_provider.identity
) )
elif provider == "stackit":
output_options = StackITOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
elif provider == "alibabacloud": elif provider == "alibabacloud":
output_options = AlibabaCloudOutputOptions( output_options = AlibabaCloudOutputOptions(
args, bulk_checks_metadata, global_provider.identity args, bulk_checks_metadata, global_provider.identity
+1
View File
@@ -78,6 +78,7 @@ class Provider(str, Enum):
SCALEWAY = "scaleway" SCALEWAY = "scaleway"
VERCEL = "vercel" VERCEL = "vercel"
OKTA = "okta" OKTA = "okta"
STACKIT = "stackit"
# Compliance # Compliance
@@ -0,0 +1,26 @@
### Project, Check and/or Region can be * to apply for all the cases.
### Project == <StackIT Project ID>
### 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$"
+25
View File
@@ -1230,6 +1230,31 @@ class CheckReportNHN(Check_Report):
self.location = getattr(resource, "location", "kr1") 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 @dataclass
class CheckReportOpenStack(Check_Report): class CheckReportOpenStack(Check_Report):
"""Contains the OpenStack Check's finding information.""" """Contains the OpenStack Check's finding information."""
+3 -2
View File
@@ -29,10 +29,10 @@ class ProwlerArgumentParser:
self.parser = argparse.ArgumentParser( self.parser = argparse.ArgumentParser(
prog="prowler", prog="prowler",
formatter_class=RawTextHelpFormatter, 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=""" epilog="""
Available Cloud Providers: 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 aws AWS Provider
azure Azure Provider azure Azure Provider
gcp GCP Provider gcp GCP Provider
@@ -44,6 +44,7 @@ Available Cloud Providers:
cloudflare Cloudflare Provider cloudflare Cloudflare Provider
oraclecloud Oracle Cloud Infrastructure Provider oraclecloud Oracle Cloud Infrastructure Provider
openstack OpenStack Provider openstack OpenStack Provider
stackit StackIT Provider
alibabacloud Alibaba Cloud Provider alibabacloud Alibaba Cloud Provider
iac IaC Provider iac IaC Provider
llm LLM Provider (Beta) llm LLM Provider (Beta)
+16
View File
@@ -342,6 +342,20 @@ class Finding(BaseModel):
output_data["resource_uid"] = check_output.resource_id output_data["resource_uid"] = check_output.resource_id
output_data["region"] = check_output.location 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": elif provider.type == "iac":
output_data["auth_method"] = provider.auth_method output_data["auth_method"] = provider.auth_method
provider_uid = getattr(provider, "provider_uid", None) provider_uid = getattr(provider, "provider_uid", None)
@@ -576,6 +590,8 @@ class Finding(BaseModel):
finding.subscription = list(provider.identity.subscriptions.keys())[0] finding.subscription = list(provider.identity.subscriptions.keys())[0]
elif provider.type == "gcp": elif provider.type == "gcp":
finding.project_id = list(provider.projects.keys())[0] finding.project_id = list(provider.projects.keys())[0]
elif provider.type == "stackit":
finding.project_id = provider.identity.project_id
elif provider.type == "iac": elif provider.type == "iac":
# For IaC, we don't have resource_line_range in the Finding model # 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 # It would need to be extracted from the resource metadata if needed
+67
View File
@@ -1076,6 +1076,73 @@ class HTML(Output):
) )
return "" 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"""
<li class="list-group-item">
<b>Project Name:</b> {project_name}
</li>"""
if project_name
else ""
)
regions_item = (
f"""
<li class="list-group-item">
<b>Regions:</b> {", ".join(sorted(audited_regions))}
</li>"""
if audited_regions
else ""
)
return f"""
<div class="col-md-2">
<div class="card">
<div class="card-header">
StackIT Assessment Summary
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Project ID:</b> {project_id}
</li>
{project_name_item}
{regions_item}
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
StackIT Credentials
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Authentication Type:</b> Service Account Key
</li>
</ul>
</div>
</div>"""
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
return ""
@staticmethod @staticmethod
def get_cloudflare_assessment_summary(provider: Provider) -> str: def get_cloudflare_assessment_summary(provider: Provider) -> str:
""" """
+2
View File
@@ -24,6 +24,8 @@ def stdout_report(finding, color, verbose, status, fix):
details = finding.location details = finding.location
if finding.check_metadata.Provider == "nhn": if finding.check_metadata.Provider == "nhn":
details = finding.location details = finding.location
if finding.check_metadata.Provider == "stackit":
details = finding.location
if finding.check_metadata.Provider == "llm": if finding.check_metadata.Provider == "llm":
details = finding.check_metadata.CheckID details = finding.check_metadata.CheckID
if finding.check_metadata.Provider == "iac": if finding.check_metadata.Provider == "iac":
+7
View File
@@ -70,6 +70,13 @@ def display_summary_table(
elif provider.type == "nhn": elif provider.type == "nhn":
entity_type = "Tenant Domain" entity_type = "Tenant Domain"
audited_entities = provider.identity.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": elif provider.type == "iac":
if provider.scan_repository_url: if provider.scan_repository_url:
entity_type = "Repository" entity_type = "Repository"
+19
View File
@@ -256,6 +256,25 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file, mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config, 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(): elif "github" in provider_class_name.lower():
orgs = [] orgs = []
repos = [] repos = []
@@ -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
)
@@ -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",
)
@@ -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)),
)
+48
View File
@@ -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-<project_id>-20230101)
if identity.project_id:
self.output_filename = f"prowler-output-stackit-{identity.project_id}-{output_file_timestamp}"
# Otherwise just 'prowler-output-stackit-<timestamp>'
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
@@ -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())
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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
@@ -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)
@@ -0,0 +1,10 @@
{
"services": {
"iaas": {
"regions": [
"eu01",
"eu02"
]
}
}
}
+3
View File
@@ -93,6 +93,9 @@ dependencies = [
"schema==0.7.5", "schema==0.7.5",
"shodan==1.31.0", "shodan==1.31.0",
"slack-sdk==3.39.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", "tabulate==0.9.0",
"tzlocal==5.3.1", "tzlocal==5.3.1",
"uuid6==2024.7.10", "uuid6==2024.7.10",
+7
View File
@@ -6,6 +6,7 @@ from unittest import mock
from requests import Response from requests import Response
from prowler.config.config import ( from prowler.config.config import (
Provider,
check_current_version, check_current_version,
get_available_compliance_frameworks, get_available_compliance_frameworks,
load_and_validate_config_file, load_and_validate_config_file,
@@ -17,6 +18,12 @@ MOCK_OLD_PROWLER_VERSION = "0.0.0"
MOCK_PROWLER_MASTER_VERSION = "3.4.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): def mock_prowler_get_latest_release(_, **_kwargs):
"""Mock requests.get() to get the Prowler latest release""" """Mock requests.get() to get the Prowler latest release"""
response = Response() response = Response()
+2 -1
View File
@@ -17,7 +17,7 @@ prowler_command = "prowler"
# capsys # capsys
# https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html # 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(): def mock_get_available_providers():
@@ -38,6 +38,7 @@ def mock_get_available_providers():
"llm", "llm",
"cloudflare", "cloudflare",
"openstack", "openstack",
"stackit",
] ]
+2
View File
@@ -160,6 +160,7 @@ class TestGetSensitiveArguments:
assert "--atlas-private-key" in result assert "--atlas-private-key" in result
assert "--nhn-password" in result assert "--nhn-password" in result
assert "--os-password" in result assert "--os-password" in result
assert "--stackit-service-account-key" in result
def test_does_not_include_non_sensitive_flags(self): def test_does_not_include_non_sensitive_flags(self):
"""Verify non-sensitive flags are not in the set.""" """Verify non-sensitive flags are not in the set."""
@@ -168,3 +169,4 @@ class TestGetSensitiveArguments:
assert "--region" not in result assert "--region" not in result
assert "--profile" not in result assert "--profile" not in result
assert "--output-formats" not in result assert "--output-formats" not in result
assert "--stackit-service-account-key-path" not in result
+103
View File
@@ -1507,3 +1507,106 @@ class TestFinding:
with pytest.raises(KeyError): with pytest.raises(KeyError):
Finding.transform_api_finding(dummy_finding, provider) 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"
+37
View File
@@ -962,6 +962,43 @@ class TestHTML:
assert summary == image_list_html_assessment_summary 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 "<b>Project ID:</b> f033ea6d-8697-40eb-a60e-acfa9128480d" in summary
assert "<b>Project Name:</b> ProwlerDev" in summary
assert "<b>Regions:</b> eu01, eu02" in summary
assert "<b>Authentication Type:</b> 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 "<b>Project ID:</b> f033ea6d-8697-40eb-a60e-acfa9128480d" in summary
assert "<b>Project Name:</b>" not in summary
def test_process_markdown_bold_text(self): def test_process_markdown_bold_text(self):
"""Test that **text** is converted to <strong>text</strong>""" """Test that **text** is converted to <strong>text</strong>"""
test_text = "This is **bold text** and this is **also bold**" test_text = "This is **bold text** and this is **also bold**"
+40
View File
@@ -1256,3 +1256,43 @@ class TestReport:
f"\t{Fore.YELLOW}INFO{Style.RESET_ALL} There are no resources" f"\t{Fore.YELLOW}INFO{Style.RESET_ALL} There are no resources"
) )
mocked_print.assert_called() # Verifying that print was called 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()
+62
View File
@@ -40,3 +40,65 @@ class TestDisplaySummaryTable:
assert "Subscriptions scanned:" in captured.out assert "Subscriptions scanned:" in captured.out
assert "Duplicate Subscription (subscription-id-1)" in captured.out assert "Duplicate Subscription (subscription-id-1)" in captured.out
assert "Duplicate Subscription (subscription-id-2)" 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
@@ -107,7 +107,9 @@ class Test_entra_managed_device_required_for_authentication:
type=None, type=None,
interval=SignInFrequencyInterval.TIME_BASED, interval=SignInFrequencyInterval.TIME_BASED,
), ),
application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), application_enforced_restrictions=ApplicationEnforcedRestrictions(
is_enabled=False
),
), ),
state=ConditionalAccessPolicyState.DISABLED, state=ConditionalAccessPolicyState.DISABLED,
) )
@@ -186,7 +188,9 @@ class Test_entra_managed_device_required_for_authentication:
type=None, type=None,
interval=SignInFrequencyInterval.TIME_BASED, interval=SignInFrequencyInterval.TIME_BASED,
), ),
application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), application_enforced_restrictions=ApplicationEnforcedRestrictions(
is_enabled=False
),
), ),
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
) )
@@ -269,7 +273,9 @@ class Test_entra_managed_device_required_for_authentication:
type=None, type=None,
interval=SignInFrequencyInterval.TIME_BASED, interval=SignInFrequencyInterval.TIME_BASED,
), ),
application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), application_enforced_restrictions=ApplicationEnforcedRestrictions(
is_enabled=False
),
), ),
state=ConditionalAccessPolicyState.ENABLED, state=ConditionalAccessPolicyState.ENABLED,
) )
@@ -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
@@ -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)
@@ -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
@@ -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"
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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", "")
@@ -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
Generated
+52
View File
@@ -3319,6 +3319,9 @@ dependencies = [
{ name = "schema" }, { name = "schema" },
{ name = "shodan" }, { name = "shodan" },
{ name = "slack-sdk" }, { name = "slack-sdk" },
{ name = "stackit-core" },
{ name = "stackit-iaas" },
{ name = "stackit-resourcemanager" },
{ name = "tabulate" }, { name = "tabulate" },
{ name = "tzlocal" }, { name = "tzlocal" },
{ name = "uuid6" }, { name = "uuid6" },
@@ -3424,6 +3427,9 @@ requires-dist = [
{ name = "schema", specifier = "==0.7.5" }, { name = "schema", specifier = "==0.7.5" },
{ name = "shodan", specifier = "==1.31.0" }, { name = "shodan", specifier = "==1.31.0" },
{ name = "slack-sdk", specifier = "==3.39.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 = "tabulate", specifier = "==0.9.0" },
{ name = "tzlocal", specifier = "==5.3.1" }, { name = "tzlocal", specifier = "==5.3.1" },
{ name = "uuid6", specifier = "==2024.7.10" }, { 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" }, { 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]] [[package]]
name = "std-uritemplate" name = "std-uritemplate"
version = "2.0.8" version = "2.0.8"