Merge branch 'master' into review-metadata-aws-neptune

This commit is contained in:
Rubén De la Torre Vico
2025-08-19 16:51:49 +02:00
124 changed files with 7584 additions and 4128 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ jobs:
working-directory: ./ui
run: npm run test:e2e
- name: Upload test reports
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: playwright-report
+5
View File
@@ -2,6 +2,11 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.12.0] (Prowler 5.11.0 - UNRELEASED)
### Added
- Lighthouse support for OpenAI GPT-5 [(#8527)](https://github.com/prowler-cloud/prowler/pull/8527)
## [1.11.0] (Prowler 5.10.0)
### Added
+4
View File
@@ -1752,6 +1752,10 @@ class LighthouseConfiguration(RowLevelSecurityProtectedModel):
GPT_4O = "gpt-4o", _("GPT-4o Default")
GPT_4O_MINI_2024_07_18 = "gpt-4o-mini-2024-07-18", _("GPT-4o Mini v2024-07-18")
GPT_4O_MINI = "gpt-4o-mini", _("GPT-4o Mini Default")
GPT_5_2025_08_07 = "gpt-5-2025-08-07", _("GPT-5 v2025-08-07")
GPT_5 = "gpt-5", _("GPT-5 Default")
GPT_5_MINI_2025_08_07 = "gpt-5-mini-2025-08-07", _("GPT-5 Mini v2025-08-07")
GPT_5_MINI = "gpt-5-mini", _("GPT-5 Mini Default")
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
+1
View File
@@ -80,6 +80,7 @@ The following list includes all the Azure checks with configurable variables tha
| `app_ensure_python_version_is_latest` | `python_latest_version` | String |
| `app_ensure_java_version_is_latest` | `java_latest_version` | String |
| `sqlserver_recommended_minimal_tls_version` | `recommended_minimal_tls_versions` | List of Strings |
| `vm_sufficient_daily_backup_retention_period` | `vm_backup_min_daily_retention_days` | Integer |
| `vm_desired_sku_size` | `desired_vm_sku_sizes` | List of Strings |
| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String |
@@ -13,7 +13,10 @@ This guide explains how to set up authentication with GitHub for Prowler. The do
Personal Access Tokens provide the simplest GitHub authentication method and support individual user authentication or testing scenarios.
#### How to Create a Personal Access Token
???+ warning "Classic Tokens Deprecated"
GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained Personal Access Tokens. We recommend using fine-grained tokens as they provide better security through more granular permissions and resource-specific access control.
#### **Option 1: Create a Fine-Grained Personal Access Token (Recommended)**
1. **Navigate to GitHub Settings**
- Open [GitHub](https://github.com) and sign in
@@ -24,18 +27,67 @@ Personal Access Tokens provide the simplest GitHub authentication method and sup
- Scroll down the left sidebar
- Click "Developer settings"
3. **Generate New Token**
3. **Generate Fine-Grained Token**
- Click "Personal access tokens"
- Select "Fine-grained tokens"
- Click "Generate new token"
4. **Configure Token Settings**
- **Token name**: Give your token a descriptive name (e.g., "Prowler Security Scanner")
- **Expiration**: Set an appropriate expiration date (recommended: 90 days or less)
- **Repository access**: Choose "All repositories" or "Only select repositories" based on your needs
???+ note "Public repositories"
Even if you select 'Only select repositories', the token will have access to the public repositories that you own or are a member of.
5. **Configure Token Permissions**
To enable Prowler functionality, configure the following permissions:
- **Repository permissions:**
- **Contents**: Read-only access
- **Metadata**: Read-only access
- **Pull requests**: Read-only access
- **Security advisories**: Read-only access
- **Statuses**: Read-only access
- **Organization permissions:**
- **Members**: Read-only access
- **Account permissions:**
- **Email addresses**: Read-only access
6. **Copy and Store the Token**
- Copy the generated token immediately (GitHub displays tokens only once)
- Store tokens securely using environment variables
![GitHub Personal Access Token Permissions](./img/github-pat-permissions.png)
#### **Option 2: Create a Classic Personal Access Token (Not Recommended)**
???+ warning "Security Risk"
Classic tokens provide broad permissions that may exceed what Prowler actually needs. Use fine-grained tokens instead for better security.
1. **Navigate to GitHub Settings**
- Open [GitHub](https://github.com) and sign in
- Click the profile picture in the top right corner
- Select "Settings" from the dropdown menu
2. **Access Developer Settings**
- Scroll down the left sidebar
- Click "Developer settings"
3. **Generate Classic Token**
- Click "Personal access tokens"
- Select "Tokens (classic)"
- Click "Generate new token"
4. **Configure Token Permissions**
To enable Prowler functionality, configure the following scopes:
- `repo`: Full control of private repositories
- `repo`: Full control of private repositories (includes `repo:status` and `repo:contents`)
- `read:org`: Read organization and team membership
- `read:user`: Read user profile data
- `read:discussion`: Read discussions
- `read:enterprise`: Read enterprise data (if applicable)
- `security_events`: Access security events (secret scanning and Dependabot alerts)
- `read:enterprise`: Read enterprise data (if using GitHub Enterprise)
5. **Copy and Store the Token**
- Copy the generated token immediately (GitHub displays tokens only once)
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+45
View File
@@ -201,6 +201,51 @@ For full setup instructions and requirements, check the [Microsoft 365 provider
<img src="../../img/m365-credentials.png" alt="Prowler Cloud M365 Credentials" width="700"/>
### **Step 4.6: GitHub Credentials**
For GitHub, you must enter your Provider ID (username or organization name) and choose the authentication method you want to use:
- **Personal Access Token** (Recommended for individual users)
- **OAuth App Token** (For applications requiring user consent)
- **GitHub App** (Recommended for organizations and production use)
???+ note
For full setup instructions and requirements, check the [GitHub provider requirements](./github/getting-started-github.md).
<img src="../img/github-auth-methods.png" alt="GitHub Authentication Methods" width="700"/>
#### **Step 4.6.1: Personal Access Token**
Personal Access Tokens provide the simplest GitHub authentication method and support individual user authentication or testing scenarios.
- Select `Personal Access Token` and enter your `Personal Access Token`:
<img src="../img/github-pat-credentials.png" alt="GitHub Personal Access Token Credentials" width="700"/>
???+ note
For detailed instructions on creating a Personal Access Token and the exact permissions required, check the [GitHub Personal Access Token tutorial](./github/getting-started-github.md#1-personal-access-token-pat).
#### **Step 4.6.2: OAuth App Token**
OAuth Apps enable applications to act on behalf of users with explicit consent.
- Select `OAuth App Token` and enter your `OAuth App Token`:
<img src="../img/github-oauth-credentials.png" alt="GitHub OAuth App Credentials" width="700"/>
???+ note
To create an OAuth App, go to GitHub Settings → Developer settings → OAuth Apps → New OAuth App. You'll need to exchange an authorization code for an access token using the OAuth flow.
#### **Step 4.6.3: GitHub App**
GitHub Apps provide the recommended integration method for accessing multiple repositories or organizations.
- Select `GitHub App` and enter your `GitHub App ID` and `GitHub App Private Key`:
<img src="../img/github-app-credentials.png" alt="GitHub App Credentials" width="700"/>
???+ note
To create a GitHub App, go to GitHub Settings → Developer settings → GitHub Apps → New GitHub App. Configure the necessary permissions and generate a private key. Install the app to your account or organization and provide the App ID and private key content.
## **Step 5: Test Connection**
After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler App can successfully connect to your provider:
+40 -1
View File
@@ -2,7 +2,46 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [v5.10.1] (Prowler UNRELEASED)
## [v5.11.0] (Prowler UNRELEASED)
### Added
- Certificate authentication for M365 provider [(#8404)](https://github.com/prowler-cloud/prowler/pull/8404)
- `vm_sufficient_daily_backup_retention_period` check for Azure provider [(#8200)](https://github.com/prowler-cloud/prowler/pull/8200)
- `vm_jit_access_enabled` check for Azure provider [(#8202)](https://github.com/prowler-cloud/prowler/pull/8202)
- Bedrock AgentCore privilege escalation combination for AWS provider [(#8526)](https://github.com/prowler-cloud/prowler/pull/8526)
- Remove standalone iam:PassRole from privesc detection and add missing patterns [(#8530)](https://github.com/prowler-cloud/prowler/pull/8530)
- `eks_cluster_deletion_protection_enabled` check for AWS provider [(#8536)](https://github.com/prowler-cloud/prowler/pull/8536)
### Changed
- Refine kisa isms-p compliance mapping [(#8479)](https://github.com/prowler-cloud/prowler/pull/8479)
### Fixed
---
## [v5.10.3] (Prowler UNRELEASED)
### Fixed
- AWS resource-arn filtering [(#8533)](https://github.com/prowler-cloud/prowler/pull/8533)
- GitHub App authentication for GitHub provider [(#8529)](https://github.com/prowler-cloud/prowler/pull/8529)
---
## [v5.10.2] (Prowler v5.10.2)
### Fixed
- Order requirements by ID in Prowler ThreatScore AWS compliance framework [(#8495)](https://github.com/prowler-cloud/prowler/pull/8495)
- Add explicit resource name to GCP and Azure Defender checks [(#8352)](https://github.com/prowler-cloud/prowler/pull/8352)
- Validation errors in Azure and M365 providers [(#8353)](https://github.com/prowler-cloud/prowler/pull/8353)
- Azure `app_http_logs_enabled` check false positives [(#8507)](https://github.com/prowler-cloud/prowler/pull/8507)
- Azure `storage_geo_redundant_enabled` check false positives [(#8504)](https://github.com/prowler-cloud/prowler/pull/8504)
- AWS `kafka_cluster_is_public` check false positives [(#8514)](https://github.com/prowler-cloud/prowler/pull/8514)
- List all accessible repositories in GitHub [(#8522)](https://github.com/prowler-cloud/prowler/pull/8522)
- GitHub CIS 1.0 Compliance Reports [(#8519)](https://github.com/prowler-cloud/prowler/pull/8519)
---
## [v5.10.1] (Prowler v5.10.1)
### Changed
- Update AWS Neptune service metadata to new format [(#8494)](https://github.com/prowler-cloud/prowler/pull/8494)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -459,6 +459,9 @@ azure:
"Standard_DS3_v2",
"Standard_D4s_v3",
]
# Azure VM Backup Configuration
# azure.vm_sufficient_daily_backup_retention_period
vm_backup_min_daily_retention_days: 7
# GCP Configuration
gcp:
+1 -3
View File
@@ -550,9 +550,7 @@ class Check_Report_GCP(Check_Report):
or ""
)
self.resource_name = (
resource_name
or getattr(resource, "name", "")
or getattr(resource, "id", "")
resource_name or getattr(resource, "name", "") or "GCP Project"
)
self.project_id = project_id or getattr(resource, "project_id", "")
self.location = (
+1 -1
View File
@@ -8,7 +8,7 @@ def is_resource_filtered(resource: str, audit_resources: list) -> bool:
Returns True if it is filtered and False if it does not match the input filters
"""
try:
if resource in str(audit_resources):
if resource in audit_resources:
return True
return False
except Exception as error:
@@ -1430,7 +1430,9 @@
"us-west-2"
],
"aws-cn": [],
"aws-us-gov": []
"aws-us-gov": [
"us-gov-west-1"
]
}
},
"bedrock-runtime": {
@@ -4269,15 +4271,18 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"me-central-1",
"me-south-1",
"sa-east-1",
@@ -5705,12 +5710,15 @@
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-5",
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"me-central-1",
"me-south-1",
"sa-east-1",
"us-east-1",
@@ -5916,9 +5924,11 @@
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-5",
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
@@ -6580,6 +6590,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -6673,6 +6684,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -8260,6 +8272,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -9914,6 +9927,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -0,0 +1,32 @@
{
"Provider": "aws",
"CheckID": "eks_cluster_deletion_protection_enabled",
"CheckTitle": "Ensure EKS clusters have deletion protection enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Resource Management"
],
"ServiceName": "eks",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "high",
"ResourceType": "AwsEksCluster",
"Description": "Ensure that your Amazon EKS clusters have deletion protection enabled to prevent accidental deletion of critical Kubernetes clusters.",
"Risk": "Without deletion protection, EKS clusters can be accidentally deleted through Terraform automation, AWS CLI commands, or the AWS console, leading to data loss and service disruption.",
"RelatedUrl": "https://docs.aws.amazon.com/eks/latest/userguide/deletion-protection.html",
"Remediation": {
"Code": {
"CLI": "aws eks update-cluster-config --region <region_name> --name <cluster_name> --deletion-protection",
"NativeIaC": "",
"Other": "",
"Terraform": "resource \"aws_eks_cluster\" \"example\" {\n name = \"example-cluster\"\n role_arn = aws_iam_role.example.arn\n deletion_protection = true\n # ... other configuration\n}"
},
"Recommendation": {
"Text": "Enable deletion protection on all EKS clusters to prevent accidental deletion. This is especially important for production clusters and those managed through Infrastructure as Code (IaC) tools.",
"Url": "https://docs.aws.amazon.com/eks/latest/userguide/deletion-protection.html"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,21 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.eks.eks_client import eks_client
class eks_cluster_deletion_protection_enabled(Check):
def execute(self):
findings = []
for cluster in eks_client.clusters:
report = Check_Report_AWS(metadata=self.metadata(), resource=cluster)
report.status = "PASS"
report.status_extended = (
f"EKS cluster {cluster.name} has deletion protection enabled."
)
if cluster.deletion_protection is False:
report.status = "FAIL"
report.status_extended = (
f"EKS cluster {cluster.name} has deletion protection disabled."
)
findings.append(report)
return findings
@@ -83,6 +83,10 @@ class EKS(AWSService):
]["publicAccessCidrs"]
if "encryptionConfig" in describe_cluster["cluster"]:
cluster.encryptionConfig = True
if "deletionProtection" in describe_cluster["cluster"]:
cluster.deletion_protection = describe_cluster["cluster"][
"deletionProtection"
]
cluster.tags = [describe_cluster["cluster"].get("tags")]
cluster.version = describe_cluster["cluster"].get("version", "")
@@ -108,4 +112,5 @@ class EKSCluster(BaseModel):
endpoint_private_access: bool = None
public_access_cidrs: list[str] = []
encryptionConfig: bool = None
deletion_protection: bool = None
tags: Optional[list] = []
@@ -24,7 +24,6 @@ privilege_escalation_policies_combination = {
"IAMPut": {"iam:Put*"},
"CreatePolicyVersion": {"iam:CreatePolicyVersion"},
"SetDefaultPolicyVersion": {"iam:SetDefaultPolicyVersion"},
"iam:PassRole": {"iam:PassRole"},
"PassRole+EC2": {
"iam:PassRole",
"ec2:RunInstances",
@@ -69,6 +68,21 @@ privilege_escalation_policies_combination = {
},
"GlueUpdateDevEndpoint": {"glue:UpdateDevEndpoint"},
"lambda:UpdateFunctionCode": {"lambda:UpdateFunctionCode"},
"lambda:UpdateFunctionConfiguration": {"lambda:UpdateFunctionConfiguration"},
"PassRole+CodeStar": {
"iam:PassRole",
"codestar:CreateProject",
},
"PassRole+CreateAutoScaling": {
"iam:PassRole",
"autoscaling:CreateAutoScalingGroup",
"autoscaling:CreateLaunchConfiguration",
},
"PassRole+UpdateAutoScaling": {
"iam:PassRole",
"autoscaling:UpdateAutoScalingGroup",
"autoscaling:CreateLaunchConfiguration",
},
"iam:CreateAccessKey": {"iam:CreateAccessKey"},
"iam:CreateLoginProfile": {"iam:CreateLoginProfile"},
"iam:UpdateLoginProfile": {"iam:UpdateLoginProfile"},
@@ -86,6 +100,12 @@ privilege_escalation_policies_combination = {
"sts:AssumeRole",
"iam:UpdateAssumeRolePolicy",
},
# AgentCore privilege escalation patterns
"PassRole+AgentCoreCreateInterpreter+InvokeInterpreter": {
"iam:PassRole",
"bedrock-agentcore:CreateCodeInterpreter",
"bedrock-agentcore:InvokeCodeInterpreter",
},
# TO-DO: We have to handle AssumeRole just if the resource is * and without conditions
# "sts:AssumeRole": {"sts:AssumeRole"},
}
@@ -10,13 +10,13 @@ class kafka_cluster_is_public(Check):
report = Check_Report_AWS(metadata=self.metadata(), resource=cluster)
report.status = "FAIL"
report.status_extended = (
f"Kafka cluster '{cluster.name}' is publicly accessible."
f"Kafka cluster {cluster.name} is publicly accessible."
)
if cluster.public_access:
if not cluster.public_access:
report.status = "PASS"
report.status_extended = (
f"Kafka cluster '{cluster.name}' is not publicly accessible."
f"Kafka cluster {cluster.name} is not publicly accessible."
)
findings.append(report)
@@ -22,6 +22,10 @@ class app_http_logs_enabled(Check):
report.status = "PASS"
report.status_extended = f"App {app.name} has HTTP Logs enabled in diagnostic setting {diagnostic_setting.name} in subscription {subscription_name}"
break
elif log.category_group == "allLogs" and log.enabled:
report.status = "PASS"
report.status_extended = f"App {app.name} has allLogs category group which includes HTTP Logs enabled in diagnostic setting {diagnostic_setting.name} in subscription {subscription_name}"
break
findings.append(report)
return findings
@@ -14,6 +14,11 @@ class defender_additional_email_configured_with_a_security_contact(Check):
report = Check_Report_Azure(
metadata=self.metadata(), resource=contact_configuration
)
report.resource_name = (
contact_configuration.name
if contact_configuration.name
else "Security Contact"
)
report.subscription = subscription_name
if len(contact_configuration.emails) > 0:
@@ -31,6 +31,11 @@ class defender_attack_path_notifications_properly_configured(Check):
report = Check_Report_Azure(
metadata=self.metadata(), resource=contact_configuration
)
report.resource_name = (
contact_configuration.name
if contact_configuration.name
else "Security Contact"
)
report.subscription = subscription_name
actual_risk_level = getattr(
contact_configuration, "attack_path_minimal_risk_level", None
@@ -14,6 +14,11 @@ class defender_ensure_notify_alerts_severity_is_high(Check):
report = Check_Report_Azure(
metadata=self.metadata(), resource=contact_configuration
)
report.resource_name = (
contact_configuration.name
if contact_configuration.name
else "Security Contact"
)
report.subscription = subscription_name
report.status = "FAIL"
report.status_extended = f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {subscription_name}."
@@ -12,7 +12,13 @@ class defender_ensure_notify_emails_to_owners(Check):
) in defender_client.security_contact_configurations.items():
for contact_configuration in security_contact_configurations.values():
report = Check_Report_Azure(
metadata=self.metadata(), resource=contact_configuration
metadata=self.metadata(),
resource=contact_configuration,
)
report.resource_name = (
contact_configuration.name
if contact_configuration.name
else "Security Contact"
)
report.subscription = subscription_name
if (
@@ -25,6 +25,7 @@ class Defender(AzureService):
).token
)
self.iot_security_solutions = self._get_iot_security_solutions()
self.jit_policies = self._get_jit_policies()
def _get_pricings(self):
logger.info("Defender - Getting pricings...")
@@ -246,6 +247,44 @@ class Defender(AzureService):
)
return iot_security_solutions
def _get_jit_policies(self) -> dict[str, dict]:
"""
Get all JIT policies for all subscriptions.
Returns:
A dictionary of JIT policies for each subscription. The format will be:
{
"subscription_name": {
"jit_policy_id": JITPolicy
}
}
"""
logger.info("Defender - Getting JIT policies...")
jit_policies = {}
for subscription_name, client in self.clients.items():
try:
jit_policies[subscription_name] = {}
policies = client.jit_network_access_policies.list()
for policy in policies:
vm_ids = set()
for vm in getattr(policy, "virtual_machines", []):
vm_ids.add(vm.id)
jit_policies[subscription_name].update(
{
policy.id: JITPolicy(
id=policy.id,
name=policy.name,
location=getattr(policy, "location", "Global"),
vm_ids=vm_ids,
),
}
)
except Exception as error:
logger.error(
f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return jit_policies
class Pricing(BaseModel):
resource_id: str
@@ -317,3 +356,10 @@ class IoTSecuritySolution(BaseModel):
resource_id: str
name: str
status: str
class JITPolicy(BaseModel):
id: str
name: str
location: str = ""
vm_ids: list[str] = []
@@ -11,20 +11,30 @@ from prowler.providers.azure.lib.service.service import AzureService
class BackupItem(BaseModel):
"""Minimal BackupItem: only essential identifying and descriptive fields."""
"""Model that represents a backup item."""
id: str
name: str
workload_type: Optional[DataSourceType]
backup_policy_id: Optional[str] = None
class BackupPolicy(BaseModel):
"""Model that represents a backup policy."""
id: str
name: str
retention_days: Optional[int] = None
class BackupVault(BaseModel):
"""Minimal BackupVault: only essential identifying fields and its backup items."""
"""Model that represents a backup vault."""
id: str
name: str
location: str
backup_protected_items: dict[str, BackupItem] = Field(default_factory=dict)
backup_policies: dict[str, BackupPolicy] = Field(default_factory=dict)
class Recovery(AzureService):
@@ -71,6 +81,9 @@ class RecoveryBackup(AzureService):
vault.backup_protected_items = self._get_backup_protected_items(
subscription_name=subscription_name, vault=vault
)
vault.backup_policies = self._get_backup_policies(
subscription_name=subscription_name, vault=vault
)
def _get_backup_protected_items(
self, subscription_name: str, vault: BackupVault
@@ -95,7 +108,58 @@ class RecoveryBackup(AzureService):
workload_type=(
item_properties.workload_type if item_properties else None
),
backup_policy_id=(
item_properties.policy_id if item_properties else None
),
)
except Exception as e:
logger.error(f"Recovery - Error getting backup protected items: {e}")
except Exception as error:
logger.error(
f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return backup_protected_items_dict
def _get_backup_policies(
self, subscription_name: str, vault: BackupVault
) -> dict[str, BackupPolicy]:
"""
Retrieve all backup policies for a given vault.
"""
logger.info("Recovery - Getting backup policies...")
backup_policies_dict: dict[str, BackupPolicy] = {}
unique_backup_policies: set[str] = set()
try:
for item in vault.backup_protected_items.values():
if item.backup_policy_id:
unique_backup_policies.add(item.backup_policy_id)
for policy_id in unique_backup_policies:
policy = self.clients[subscription_name].protection_policies.get(
vault_name=vault.name,
resource_group_name=vault.id.split("/")[4],
policy_name=policy_id.split("/")[-1],
)
backup_policies_dict[policy_id] = BackupPolicy(
id=policy.id,
name=policy.name,
retention_days=getattr(
getattr(
getattr(
getattr(
getattr(policy, "properties", None),
"retention_policy",
None,
),
"daily_schedule",
None,
),
"retention_duration",
None,
),
"count",
None,
),
)
except Exception as error:
logger.error(
f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return backup_policies_dict
@@ -1,6 +1,5 @@
from prowler.lib.check.models import Check, Check_Report_Azure
from prowler.providers.azure.services.storage.storage_client import storage_client
from prowler.providers.azure.services.storage.storage_service import ReplicationSettings
class storage_geo_redundant_enabled(Check):
@@ -27,14 +26,16 @@ class storage_geo_redundant_enabled(Check):
report.subscription = subscription
if (
storage_account.replication_settings
== ReplicationSettings.STANDARD_GRS
storage_account.replication_settings == "Standard_GRS"
or storage_account.replication_settings == "Standard_GZRS"
or storage_account.replication_settings == "Standard_RAGRS"
or storage_account.replication_settings == "Standard_RAGZRS"
):
report.status = "PASS"
report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has Geo-redundant storage (GRS) enabled."
report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has Geo-redundant storage {storage_account.replication_settings} enabled."
else:
report.status = "FAIL"
report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not have Geo-redundant storage (GRS) enabled."
report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not have Geo-redundant storage enabled, it has {storage_account.replication_settings} instead."
findings.append(report)
@@ -1,4 +1,3 @@
from enum import Enum
from typing import Optional
from azure.mgmt.storage import StorageManagementClient
@@ -35,7 +34,6 @@ class Storage(AzureService):
key_expiration_period_in_days = int(
storage_account.key_policy.key_expiration_period_in_days
)
replication_settings = ReplicationSettings(storage_account.sku.name)
storage_accounts[subscription].append(
Account(
id=storage_account.id,
@@ -84,7 +82,7 @@ class Storage(AzureService):
False,
)
),
replication_settings=replication_settings,
replication_settings=storage_account.sku.name,
allow_cross_tenant_replication=(
True
if getattr(
@@ -273,17 +271,6 @@ class PrivateEndpointConnection(BaseModel):
type: str
class ReplicationSettings(Enum):
STANDARD_LRS = "Standard_LRS"
STANDARD_GRS = "Standard_GRS"
STANDARD_RAGRS = "Standard_RAGRS"
STANDARD_ZRS = "Standard_ZRS"
PREMIUM_LRS = "Premium_LRS"
PREMIUM_ZRS = "Premium_ZRS"
STANDARD_GZRS = "Standard_GZRS"
STANDARD_RAGZRS = "Standard_RAGZRS"
class SMBProtocolSettings(BaseModel):
channel_encryption: list[str]
supported_versions: list[str]
@@ -310,7 +297,7 @@ class Account(BaseModel):
minimum_tls_version: str
private_endpoint_connections: list[PrivateEndpointConnection]
key_expiration_period_in_days: Optional[int] = None
replication_settings: ReplicationSettings = ReplicationSettings.STANDARD_LRS
replication_settings: str = "Standard_LRS"
allow_cross_tenant_replication: bool = True
allow_shared_key_access: bool = True
blob_properties: Optional[BlobProperties] = None
@@ -0,0 +1,30 @@
{
"Provider": "azure",
"CheckID": "vm_jit_access_enabled",
"CheckTitle": "Enable Just-In-Time Access for Virtual Machines",
"CheckType": [],
"ServiceName": "vm",
"SubServiceName": "",
"ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}",
"Severity": "high",
"ResourceType": "Microsoft.Compute/virtualMachines",
"Description": "Ensure that Microsoft Azure virtual machines are configured to use Just-in-Time (JIT) access.",
"Risk": "Without JIT access, management ports such as 22 (SSH) and 3389 (RDP) may be exposed, increasing the risk of brute-force and DDoS attacks.",
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-just-in-time?tabs=jit-config-asc%2Cjit-request-asc",
"Remediation": {
"Code": {
"CLI": "az security jit-policy list --query '[*].virtualMachines[*].id | []'",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable Just-in-Time (JIT) network access for your Microsoft Azure virtual machines using the Azure Portal under Security Center > Just-in-time VM access.",
"Url": "https://docs.microsoft.com/en-us/azure/security-center/security-center-just-in-time?tabs=jit-config-asc%2Cjit-request-asc"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": "JIT access can only be enabled via the Azure Portal. Ensure Security Center standard pricing tier for servers is enabled."
}
@@ -0,0 +1,33 @@
from prowler.lib.check.models import Check, Check_Report_Azure
from prowler.providers.azure.services.defender.defender_client import defender_client
from prowler.providers.azure.services.vm.vm_client import vm_client
class vm_jit_access_enabled(Check):
"""
Ensure that Microsoft Azure virtual machines are configured to use Just-in-Time (JIT) access.
This check evaluates whether JIT access is enabled for each VM to reduce the attack surface.
- PASS: VM has JIT access enabled.
- FAIL: VM does not have JIT access enabled.
"""
def execute(self):
findings = []
jit_enabled_vms = set()
for subscription_name, vms in vm_client.virtual_machines.items():
for jit_policy in defender_client.jit_policies[subscription_name].values():
jit_enabled_vms.update(jit_policy.vm_ids)
for vm in vms.values():
report = Check_Report_Azure(metadata=self.metadata(), resource=vm)
report.subscription = subscription_name
if vm.resource_id.lower() in {
vm_id.lower() for vm_id in jit_enabled_vms
}:
report.status = "PASS"
report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} has JIT (Just-in-Time) access enabled."
else:
report.status = "FAIL"
report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} does not have JIT (Just-in-Time) access enabled."
findings.append(report)
return findings
@@ -0,0 +1,30 @@
{
"Provider": "azure",
"CheckID": "vm_sufficient_daily_backup_retention_period",
"CheckTitle": "Ensure there is a sufficient daily backup retention period configured for Azure virtual machines.",
"CheckType": [],
"ServiceName": "vm",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Microsoft.Compute/virtualMachines",
"Description": "Ensure there is a sufficient daily backup retention period configured for Azure virtual machines.",
"Risk": "Having an optimal daily backup retention period for your Azure virtual machines will enforce your backup strategy to follow the best practices as specified in the compliance regulations promoted by your organization. Retaining VM backups for a longer period of time will allow you to handle more efficiently your data restoration process in the event of a failure.",
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/backup/backup-azure-vms-introduction",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/sufficient-backup-retention-period.html",
"Terraform": ""
},
"Recommendation": {
"Text": "Set the daily backup retention period for each VM's backup policy to meet or exceed your organization's minimum requirement.",
"Url": "https://docs.microsoft.com/en-us/azure/backup/backup-azure-vms-introduction"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,51 @@
from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType
from prowler.lib.check.models import Check, Check_Report_Azure
from prowler.providers.azure.services.recovery.recovery_client import recovery_client
from prowler.providers.azure.services.vm.vm_client import vm_client
class vm_sufficient_daily_backup_retention_period(Check):
"""
Ensure there is a sufficient daily backup retention period configured for Azure virtual machines.
- PASS: The VM has a backup policy with sufficient daily retention period.
- FAIL: The VM does not have a backup policy or the retention period is insufficient.
"""
def execute(self) -> list[Check_Report_Azure]:
findings = []
min_retention_days = getattr(vm_client, "audit_config", {}).get(
"vm_backup_min_daily_retention_days", 7
)
for subscription, vms in vm_client.virtual_machines.items():
vaults = recovery_client.vaults.get(subscription, {})
for vm in vms.values():
backup_found = False
retention_days = None
for vault in vaults.values():
for backup_item in vault.backup_protected_items.values():
if (
backup_item.workload_type == DataSourceType.VM
and backup_item.name.split(";")[-1] == vm.resource_name
):
backup_found = True
policy_id = backup_item.backup_policy_id
if policy_id and policy_id in vault.backup_policies:
retention_days = vault.backup_policies[
policy_id
].retention_days
break
if backup_found:
break
if backup_found and retention_days:
report = Check_Report_Azure(metadata=self.metadata(), resource=vm)
report.subscription = subscription
if retention_days >= min_retention_days:
report.status = "PASS"
report.status_extended = f"VM {vm.resource_name} in subscription {subscription} has a daily backup retention period of {retention_days} days (minimum required: {min_retention_days})."
else:
report.status = "FAIL"
report.status_extended = f"VM {vm.resource_name} in subscription {subscription} has insufficient daily backup retention period of {retention_days} days (minimum required: {min_retention_days})."
findings.append(report)
return findings
+2
View File
@@ -222,6 +222,8 @@ class Provider(ABC):
env_auth=arguments.env_auth,
az_cli_auth=arguments.az_cli_auth,
browser_auth=arguments.browser_auth,
certificate_auth=arguments.certificate_auth,
certificate_path=arguments.certificate_path,
tenant_id=arguments.tenant_id,
init_modules=arguments.init_modules,
fixer_config=fixer_config,
+20 -2
View File
@@ -659,6 +659,9 @@ class GcpProvider(Provider):
if asset["resource"]["data"].get("name")
else project_id
)
# Handle empty or null project names
if not project_name or project_name.strip() == "":
project_name = "GCP Project"
gcp_project = GCPProject(
number=project_number,
id=project_id,
@@ -717,6 +720,9 @@ class GcpProvider(Provider):
if project.get("name")
else project_id
)
# Handle empty or null project names
if not project_name or project_name.strip() == "":
project_name = "GCP Project"
project_id = project["projectId"]
gcp_project = GCPProject(
number=project_number,
@@ -757,9 +763,15 @@ class GcpProvider(Provider):
# If no projects were able to be accessed via API, add them manually if provided by the user in arguments
if project_ids:
for input_project in project_ids:
# Handle empty or null project names
project_name = (
input_project
if input_project and input_project.strip() != ""
else "GCP Project"
)
projects[input_project] = GCPProject(
id=input_project,
name=input_project,
name=project_name,
number=0,
labels={},
lifecycle_state="ACTIVE",
@@ -768,9 +780,15 @@ class GcpProvider(Provider):
elif credentials_file:
with open(credentials_file, "r", encoding="utf-8") as file:
project_id = json.load(file)["project_id"]
# Handle empty or null project names
project_name = (
project_id
if project_id and project_id.strip() != ""
else "GCP Project"
)
projects[project_id] = GCPProject(
id=project_id,
name=project_id,
name=project_name,
number=0,
labels={},
lifecycle_state="ACTIVE",
@@ -13,7 +13,7 @@ class iam_no_service_roles_at_project_level(Check):
metadata=self.metadata(),
resource=binding,
resource_id=binding.role,
resource_name=binding.role,
resource_name=binding.role if binding.role else "Service Role",
location=cloudresourcemanager_client.region,
)
if binding.role in [
@@ -31,7 +31,6 @@ class iam_no_service_roles_at_project_level(Check):
metadata=self.metadata(),
resource=cloudresourcemanager_client.projects[project],
project_id=project,
resource_name=project,
location=cloudresourcemanager_client.region,
)
report.status = "PASS"
@@ -20,6 +20,7 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable
metadata=self.metadata(),
resource=metric,
location=logging_client.region,
resource_name=metric.name if metric.name else "Log Metric Filter",
)
projects_with_metric.add(metric.project_id)
report.status = "FAIL"
@@ -18,6 +18,7 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(
metadata=self.metadata(),
resource=metric,
location=logging_client.region,
resource_name=metric.name if metric.name else "Log Metric Filter",
)
projects_with_metric.add(metric.project_id)
report.status = "FAIL"
@@ -18,6 +18,7 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check)
metadata=self.metadata(),
resource=metric,
location=logging_client.region,
resource_name=metric.name if metric.name else "Log Metric Filter",
)
projects_with_metric.add(metric.project_id)
report.status = "FAIL"
@@ -18,6 +18,7 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(
metadata=self.metadata(),
resource=metric,
location=logging_client.region,
resource_name=metric.name if metric.name else "Log Metric Filter",
)
projects_with_metric.add(metric.project_id)
report.status = "FAIL"
@@ -17,6 +17,7 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes
metadata=self.metadata(),
resource=metric,
location=logging_client.region,
resource_name=metric.name if metric.name else "Log Metric Filter",
)
projects_with_metric.add(metric.project_id)
report.status = "FAIL"
@@ -18,6 +18,7 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(
metadata=self.metadata(),
resource=metric,
location=logging_client.region,
resource_name=metric.name if metric.name else "Log Metric Filter",
)
projects_with_metric.add(metric.project_id)
report.status = "FAIL"
@@ -18,6 +18,7 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check)
metadata=self.metadata(),
resource=metric,
location=logging_client.region,
resource_name=metric.name if metric.name else "Log Metric Filter",
)
projects_with_metric.add(metric.project_id)
report.status = "FAIL"
@@ -18,6 +18,7 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(
metadata=self.metadata(),
resource=metric,
location=logging_client.region,
resource_name=metric.name if metric.name else "Log Metric Filter",
)
projects_with_metric.add(metric.project_id)
report.status = "FAIL"
@@ -26,6 +26,11 @@ class logging_sink_created(Check):
metadata=self.metadata(),
resource=projects_with_logging_sink[project],
location=logging_client.region,
resource_name=(
projects_with_logging_sink[project].name
if projects_with_logging_sink[project].name
else "Logging Sink"
),
)
report.status = "PASS"
report.status_extended = f"Sink {projects_with_logging_sink[project].name} is enabled exporting copies of all the log entries in project {project}."
+15 -1
View File
@@ -133,6 +133,12 @@ class GithubProvider(Provider):
"""
logger.info("Instantiating GitHub Provider...")
# Mute GitHub library logs to reduce noise since it is already handled by the Prowler logger
import logging
logging.getLogger("github").setLevel(logging.CRITICAL)
logging.getLogger("github.GithubRetry").setLevel(logging.CRITICAL)
# Set repositories and organizations for scoping
self._repositories = repositories or []
self._organizations = organizations or []
@@ -359,8 +365,16 @@ class GithubProvider(Provider):
elif session.id != 0 and session.key:
auth = Auth.AppAuth(session.id, session.key)
gi = GithubIntegration(auth=auth, retry=retry_config)
installations = []
for installation in gi.get_installations():
installations.append(
installation.raw_data.get("account", {}).get("login")
)
try:
identity = GithubAppIdentityInfo(app_id=gi.get_app().id)
identity = GithubAppIdentityInfo(
app_id=gi.get_app().id,
installations=installations,
)
return identity
except Exception as error:
+1
View File
@@ -18,6 +18,7 @@ class GithubIdentityInfo(BaseModel):
class GithubAppIdentityInfo(BaseModel):
app_id: str
installations: list[str]
class GithubOutputOptions(ProviderOutputOptions):
@@ -2,10 +2,12 @@ from datetime import datetime
from typing import Optional
import github
import requests
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.github.lib.service.service import GithubService
from prowler.providers.github.models import GithubAppIdentityInfo
class Repository(GithubService):
@@ -50,31 +52,74 @@ class Repository(GithubService):
return True
def _get_accessible_repos_graphql(self) -> list[str]:
"""
Use the GitHub GraphQL API to list all repositories that the authentication token has access to.
This works with high-granularity (fine-grained) PATs.
"""
graphql_url = "https://api.github.com/graphql"
token = self.provider.session.token
headers = {
"Authorization": f"bearer {token}",
"Content-Type": "application/json",
}
query = """
{
viewer {
repositories(first: 100, affiliations: [OWNER, ORGANIZATION_MEMBER]) {
nodes {
nameWithOwner
}
}
}
}
"""
try:
response = requests.post(
graphql_url, json={"query": query}, headers=headers
)
response.raise_for_status()
data = response.json()
if "errors" in data:
logger.error(f"Error in GraphQL query: {data['errors']}")
return []
repo_nodes = (
data.get("data", {})
.get("viewer", {})
.get("repositories", {})
.get("nodes", [])
)
return [repo["nameWithOwner"] for repo in repo_nodes]
except requests.exceptions.RequestException as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return []
def _list_repositories(self):
"""
List repositories based on provider scoping configuration.
Scoping behavior:
- No scoping: Returns all accessible repositories for authenticated user
- Repository scoping: Returns only specified repositories
Example: --repository owner1/repo1 owner2/repo2
- Organization scoping: Returns all repositories from specified organizations
Example: --organization org1 org2
- Combined scoping: Returns specified repositories + all repos from organizations
Example: --repository owner1/repo1 --organization org2
Returns:
dict: Dictionary of repository ID to Repo objects
Raises:
github.GithubException: When GitHub API access fails
github.RateLimitExceededException: When API rate limits are exceeded
If the provider is a GitHub App, it will list repositories in the organizations that the GitHub App is installed in.
If the provider is a user, it will list repositories where the user is a member or owner.
If input repositories are provided, it will list repositories that match the input repositories.
If input organizations are provided, it will list repositories in the organizations that match the input organizations.
"""
logger.info("Repository - Listing Repositories...")
repos = {}
try:
for client in self.clients:
if self.provider.repositories or self.provider.organizations:
if (
self.provider.repositories
or self.provider.organizations
or (
isinstance(self.provider.identity, GithubAppIdentityInfo)
and self.provider.identity.installations
)
):
if self.provider.repositories:
logger.info(
f"Filtering for specific repositories: {self.provider.repositories}"
@@ -108,12 +153,57 @@ class Repository(GithubService):
self._handle_github_api_error(
error, "processing organization", org_name
)
if (
isinstance(self.provider.identity, GithubAppIdentityInfo)
and self.provider.identity.installations
):
logger.info(
f"Filtering for repositories in the organizations or accounts that the GitHub App is installed in: {', '.join(self.provider.identity.installations)}"
)
for org_name in self.provider.identity.installations:
try:
repos_list, _ = self._get_repositories_from_owner(
client, org_name
)
for repo in repos_list:
self._process_repository(repo, repos)
except Exception as error:
self._handle_github_api_error(
error, "processing organization", org_name
)
else:
for repo in client.get_user().get_repos():
self._process_repository(repo, repos)
logger.info(
"No repository or organization specified, discovering accessible repositories via GraphQL API..."
)
accessible_repo_names = self._get_accessible_repos_graphql()
if not accessible_repo_names:
logger.warning(
"Could not find any accessible repositories with the provided token."
)
for repo_name in accessible_repo_names:
try:
repo = client.get_repo(repo_name)
logger.info(
f"Processing repository found via GraphQL: {repo.full_name}"
)
self._process_repository(repo, repos)
except Exception as error:
if hasattr(self, "_handle_github_api_error"):
self._handle_github_api_error(
error,
"accessing repository discovered via GraphQL",
repo_name,
)
else:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except github.RateLimitExceededException as error:
logger.error(f"GitHub API rate limit exceeded: {error}")
raise # Re-raise rate limit errors as they need special handling
raise
except github.GithubException as error:
logger.error(f"GitHub API error while listing repositories: {error}")
except Exception as error:
@@ -124,158 +214,167 @@ class Repository(GithubService):
def _process_repository(self, repo, repos):
"""Process a single repository and extract all its information."""
default_branch = repo.default_branch
securitymd_exists = self._file_exists(repo, "SECURITY.md")
# CODEOWNERS file can be in .github/, root, or docs/
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location
codeowners_paths = [
".github/CODEOWNERS",
"CODEOWNERS",
"docs/CODEOWNERS",
]
codeowners_files = [self._file_exists(repo, path) for path in codeowners_paths]
if True in codeowners_files:
codeowners_exists = True
elif all(file is None for file in codeowners_files):
codeowners_exists = None
else:
codeowners_exists = False
delete_branch_on_merge = (
repo.delete_branch_on_merge
if repo.delete_branch_on_merge is not None
else False
)
require_pr = False
approval_cnt = 0
branch_protection = False
required_linear_history = False
allow_force_pushes = True
branch_deletion = True
require_code_owner_reviews = False
require_signed_commits = False
status_checks = False
enforce_admins = False
conversation_resolution = False
try:
branch = repo.get_branch(default_branch)
if branch.protected:
protection = branch.get_protection()
if protection:
require_pr = protection.required_pull_request_reviews is not None
approval_cnt = (
protection.required_pull_request_reviews.required_approving_review_count
if require_pr
else 0
)
required_linear_history = protection.required_linear_history
allow_force_pushes = protection.allow_force_pushes
branch_deletion = protection.allow_deletions
status_checks = protection.required_status_checks is not None
enforce_admins = protection.enforce_admins
conversation_resolution = (
protection.required_conversation_resolution
)
branch_protection = True
require_code_owner_reviews = (
protection.required_pull_request_reviews.require_code_owner_reviews
if require_pr
else False
)
require_signed_commits = branch.get_required_signatures()
except Exception as error:
# If the branch is not found, it is not protected
if "404" in str(error):
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
# Any other error, we cannot know if the branch is protected or not
default_branch = repo.default_branch
securitymd_exists = self._file_exists(repo, "SECURITY.md")
# CODEOWNERS file can be in .github/, root, or docs/
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location
codeowners_paths = [
".github/CODEOWNERS",
"CODEOWNERS",
"docs/CODEOWNERS",
]
codeowners_files = [
self._file_exists(repo, path) for path in codeowners_paths
]
if True in codeowners_files:
codeowners_exists = True
elif all(file is None for file in codeowners_files):
codeowners_exists = None
else:
require_pr = None
approval_cnt = None
branch_protection = None
required_linear_history = None
allow_force_pushes = None
branch_deletion = None
require_code_owner_reviews = None
require_signed_commits = None
status_checks = None
enforce_admins = None
conversation_resolution = None
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
codeowners_exists = False
delete_branch_on_merge = (
repo.delete_branch_on_merge
if repo.delete_branch_on_merge is not None
else False
)
secret_scanning_enabled = False
dependabot_alerts_enabled = False
try:
if (
repo.security_and_analysis
and repo.security_and_analysis.secret_scanning
):
secret_scanning_enabled = (
repo.security_and_analysis.secret_scanning.status == "enabled"
)
require_pr = False
approval_cnt = 0
branch_protection = False
required_linear_history = False
allow_force_pushes = True
branch_deletion = True
require_code_owner_reviews = False
require_signed_commits = False
status_checks = False
enforce_admins = False
conversation_resolution = False
try:
# Use get_dependabot_alerts to check if Dependabot alerts are enabled
repo.get_dependabot_alerts().totalCount
# If the call succeeds, Dependabot is enabled (even if no alerts)
dependabot_alerts_enabled = True
branch = repo.get_branch(default_branch)
if branch.protected:
protection = branch.get_protection()
if protection:
require_pr = (
protection.required_pull_request_reviews is not None
)
approval_cnt = (
protection.required_pull_request_reviews.required_approving_review_count
if require_pr
else 0
)
required_linear_history = protection.required_linear_history
allow_force_pushes = protection.allow_force_pushes
branch_deletion = protection.allow_deletions
status_checks = protection.required_status_checks is not None
enforce_admins = protection.enforce_admins
conversation_resolution = (
protection.required_conversation_resolution
)
branch_protection = True
require_code_owner_reviews = (
protection.required_pull_request_reviews.require_code_owner_reviews
if require_pr
else False
)
require_signed_commits = branch.get_required_signatures()
except Exception as error:
error_str = str(error)
if (
"403" in error_str
and "Dependabot alerts are disabled for this repository."
in error_str
):
dependabot_alerts_enabled = False
else:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
# If the branch is not found, it is not protected
if "404" in str(error):
logger.warning(
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
dependabot_alerts_enabled = None
# Any other error, we cannot know if the branch is protected or not
else:
require_pr = None
approval_cnt = None
branch_protection = None
required_linear_history = None
allow_force_pushes = None
branch_deletion = None
require_code_owner_reviews = None
require_signed_commits = None
status_checks = None
enforce_admins = None
conversation_resolution = None
logger.error(
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
secret_scanning_enabled = False
dependabot_alerts_enabled = False
try:
if (
repo.security_and_analysis
and repo.security_and_analysis.secret_scanning
):
secret_scanning_enabled = (
repo.security_and_analysis.secret_scanning.status == "enabled"
)
try:
# Use get_dependabot_alerts to check if Dependabot alerts are enabled
repo.get_dependabot_alerts().totalCount
# If the call succeeds, Dependabot is enabled (even if no alerts)
dependabot_alerts_enabled = True
except Exception as error:
error_str = str(error)
if (
"403" in error_str
and "Dependabot alerts are disabled for this repository."
in error_str
):
dependabot_alerts_enabled = False
else:
logger.error(
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
dependabot_alerts_enabled = None
except Exception as error:
logger.error(
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
secret_scanning_enabled = None
dependabot_alerts_enabled = None
repos[repo.id] = Repo(
id=repo.id,
name=repo.name,
owner=repo.owner.login,
full_name=repo.full_name,
default_branch=Branch(
name=default_branch,
protected=branch_protection,
default_branch=True,
require_pull_request=require_pr,
approval_count=approval_cnt,
required_linear_history=required_linear_history,
allow_force_pushes=allow_force_pushes,
branch_deletion=branch_deletion,
status_checks=status_checks,
enforce_admins=enforce_admins,
conversation_resolution=conversation_resolution,
require_code_owner_reviews=require_code_owner_reviews,
require_signed_commits=require_signed_commits,
),
private=repo.private,
archived=repo.archived,
pushed_at=repo.pushed_at,
securitymd=securitymd_exists,
codeowners_exists=codeowners_exists,
secret_scanning_enabled=secret_scanning_enabled,
dependabot_alerts_enabled=dependabot_alerts_enabled,
delete_branch_on_merge=delete_branch_on_merge,
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
secret_scanning_enabled = None
dependabot_alerts_enabled = None
repos[repo.id] = Repo(
id=repo.id,
name=repo.name,
owner=repo.owner.login,
full_name=repo.full_name,
default_branch=Branch(
name=default_branch,
protected=branch_protection,
default_branch=True,
require_pull_request=require_pr,
approval_count=approval_cnt,
required_linear_history=required_linear_history,
allow_force_pushes=allow_force_pushes,
branch_deletion=branch_deletion,
status_checks=status_checks,
enforce_admins=enforce_admins,
conversation_resolution=conversation_resolution,
require_code_owner_reviews=require_code_owner_reviews,
require_signed_commits=require_signed_commits,
),
private=repo.private,
archived=repo.archived,
pushed_at=repo.pushed_at,
securitymd=securitymd_exists,
codeowners_exists=codeowners_exists,
secret_scanning_enabled=secret_scanning_enabled,
dependabot_alerts_enabled=dependabot_alerts_enabled,
delete_branch_on_merge=delete_branch_on_merge,
)
class Branch(BaseModel):
"""Model for Github Branch"""
name: str
protected: bool
protected: Optional[bool]
default_branch: bool
require_pull_request: Optional[bool]
approval_count: Optional[int]
@@ -126,6 +126,18 @@ class M365BaseException(ProwlerException):
"message": "Failed to establish connection to Exchange Online API.",
"remediation": "Ensure the application has proper permission granted to access Exchange Online.",
},
(6030, "M365CertificateCreationError"): {
"message": "Failed to create X.509 certificate object from provided certificate content.",
"remediation": "Ensure the certificate content is valid base64 encoded X.509 certificate data and is properly formatted.",
},
(6031, "M365NotValidCertificateContentError"): {
"message": "The provided certificate content is not valid base64 encoded data.",
"remediation": "Ensure the certificate content is valid base64 encoded X.509 certificate data without line breaks or invalid characters.",
},
(6032, "M365NotValidCertificatePathError"): {
"message": "The provided certificate path is not valid or the file cannot be accessed.",
"remediation": "Ensure the certificate path exists, is accessible, and points to a valid certificate file.",
},
}
def __init__(self, code, file=None, original_exception=None, message=None):
@@ -357,3 +369,24 @@ class M365ExchangeConnectionError(M365CredentialsError):
super().__init__(
6029, file=file, original_exception=original_exception, message=message
)
class M365CertificateCreationError(M365CredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
6030, file=file, original_exception=original_exception, message=message
)
class M365NotValidCertificateContentError(M365CredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
6031, file=file, original_exception=original_exception, message=message
)
class M365NotValidCertificatePathError(M365CredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
6032, file=file, original_exception=original_exception, message=message
)
@@ -28,6 +28,17 @@ def init_parser(self):
action="store_true",
help="Use Azure interactive browser authentication to log in against Microsoft 365",
)
m365_auth_modes_group.add_argument(
"--certificate-auth",
action="store_true",
help="Use Certificate authentication to log in against Microsoft 365",
)
m365_parser.add_argument(
"--certificate-path",
nargs="?",
default=None,
help="Path to the certificate file to be used with --certificate-auth option",
)
m365_parser.add_argument(
"--tenant-id",
nargs="?",
@@ -4,6 +4,7 @@ import platform
from prowler.lib.logger import logger
from prowler.lib.powershell.powershell import PowerShellSession
from prowler.providers.m365.exceptions.exceptions import (
M365CertificateCreationError,
M365GraphConnectionError,
M365UserCredentialsError,
M365UserNotBelongingToTenantError,
@@ -51,23 +52,71 @@ class M365PowerShell(PowerShellSession):
self.tenant_identity = identity
self.init_credential(credentials)
def clean_certificate_content(self, cert_content: str) -> str:
"""
Clean certificate content for PowerShell consumption.
Removes newlines, carriage returns, and extra spaces from base64 content
to ensure proper parsing in PowerShell.
Args:
cert_content (str): Base64 encoded certificate content
Returns:
str: Cleaned base64 certificate content
"""
# Clean base64 content - remove any newlines or whitespace
clean_content = (
cert_content.strip().replace("\n", "").replace("\r", "").replace(" ", "")
)
logger.info(f"Cleaned certificate content length: {len(clean_content)}")
return clean_content
def init_credential(self, credentials: M365Credentials) -> None:
"""
Initialize PowerShell credential object for Microsoft 365 authentication.
Sanitizes the username and password, then creates a PSCredential object
in the PowerShell session for use with Microsoft 365 cmdlets.
Supports three authentication methods:
1. User authentication (username/password) - Will be deprecated in September 2025
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
Args:
credentials (M365Credentials): The credentials object containing
username and password.
authentication information.
Note:
The credentials are sanitized to prevent command injection and
stored securely in the PowerShell session.
"""
# Certificate Auth
if credentials.certificate_content and credentials.client_id:
# Clean certificate content for PowerShell consumption
clean_cert_content = self.clean_certificate_content(
credentials.certificate_content
)
# Sanitize credentials
sanitized_client_id = self.sanitize(credentials.client_id)
sanitized_tenant_id = self.sanitize(credentials.tenant_id)
self.execute(
f'$certBytes = [Convert]::FromBase64String("{clean_cert_content}")'
)
error = self.execute(
"$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certBytes)"
)
if error:
raise M365CertificateCreationError(
f"[{os.path.basename(__file__)}] Error creating certificate: {error}"
)
self.execute(f'$clientID = "{sanitized_client_id}"')
self.execute(f'$tenantID = "{sanitized_tenant_id}"')
self.execute(f'$tenantDomain = "{credentials.tenant_domains[0]}"')
# User Auth (Will be deprecated in September 2025)
if credentials.user and credentials.passwd:
elif credentials.user and credentials.passwd:
credentials.encrypted_passwd = self.encrypt_password(credentials.passwd)
# Sanitize user and password
@@ -135,14 +184,28 @@ class M365PowerShell(PowerShellSession):
"""
Test Microsoft 365 credentials by attempting to authenticate against Entra ID.
Supports testing three authentication methods:
1. User authentication (username/password)
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
Args:
credentials (M365Credentials): The credentials object containing
username and password to test.
authentication information to test.
Returns:
bool: True if credentials are valid and authentication succeeds, False otherwise.
"""
if credentials.user and credentials.passwd:
# Test Certificate Auth
if credentials.certificate_content and credentials.client_id:
try:
self.test_teams_certificate_connection() or self.test_exchange_certificate_connection()
return True
except Exception as e:
logger.error(f"Exchange Online Certificate connection failed: {e}")
# Test User Auth
elif credentials.user and credentials.passwd:
self.execute(
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized
)
@@ -161,6 +224,7 @@ class M365PowerShell(PowerShellSession):
)
# Validate credentials
# Test Exchange Online connection
result = self.execute("Connect-ExchangeOnline -Credential $credential")
if "https://aka.ms/exov3-module" not in result:
if "AADSTS" in result: # Entra Security Token Service Error
@@ -168,21 +232,19 @@ class M365PowerShell(PowerShellSession):
file=os.path.basename(__file__),
message=result,
)
else: # Could not connect to Exchange Online, try Microsoft Teams
result = self.execute(
"Connect-MicrosoftTeams -Credential $credential"
)
if self.tenant_identity.tenant_id not in result:
if "AADSTS" in result: # Entra Security Token Service Error
raise M365UserCredentialsError(
file=os.path.basename(__file__),
message=result,
)
else: # Unknown error, could be a permission issue or modules not installed
raise Exception(
file=os.path.basename(__file__),
message=f"Error connecting to PowerShell modules: {result}",
)
# Test Microsoft Teams connection
result = self.execute("Connect-MicrosoftTeams -Credential $credential")
if self.tenant_identity.user not in result:
if "AADSTS" in result: # Entra Security Token Service Error
raise M365UserCredentialsError(
file=os.path.basename(__file__),
message=result,
)
else: # Unknown error, could be a permission issue or modules not installed
raise M365UserCredentialsError(
file=os.path.basename(__file__),
message=f"Error connecting to PowerShell modules: {result if result else 'Unknown error'}",
)
return True
@@ -235,6 +297,9 @@ class M365PowerShell(PowerShellSession):
"Microsoft Teams connection failed: Please check your permissions and try again."
)
return False
self.execute(
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
)
return True
except Exception as e:
logger.error(
@@ -242,6 +307,31 @@ class M365PowerShell(PowerShellSession):
)
return False
def test_teams_certificate_connection(self) -> bool:
"""Test Microsoft Teams API connection using certificate and raise exception if it fails."""
result = self.execute(
"Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID"
)
if self.tenant_identity.identity_id not in result:
logger.error(f"Microsoft Teams Certificate connection failed: {result}")
return False
return True
def test_teams_user_connection(self) -> bool:
"""Test Microsoft Teams API connection using user authentication and raise exception if it fails."""
result = self.execute("Connect-MicrosoftTeams -Credential $credential")
if self.tenant_identity.user not in result:
logger.error(f"Microsoft Teams User Auth connection failed: {result}.")
return False
connection = self.execute("Get-CsTeamsClientConfiguration")
if not connection:
logger.error(
"Microsoft Teams User Auth connection failed: Please check your permissions and try again."
)
return False
return True
def test_exchange_connection(self) -> bool:
"""Test Exchange Online API connection and raise exception if it fails."""
try:
@@ -258,6 +348,9 @@ class M365PowerShell(PowerShellSession):
"Exchange Online connection failed: Please check your permissions and try again."
)
return False
self.execute(
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"'
)
return True
except Exception as e:
logger.error(
@@ -265,11 +358,40 @@ class M365PowerShell(PowerShellSession):
)
return False
def test_exchange_certificate_connection(self) -> bool:
"""Test Exchange Online API connection using certificate and raise exception if it fails."""
result = self.execute(
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain"
)
if "https://aka.ms/exov3-module" not in result:
logger.error(f"Exchange Online Certificate connection failed: {result}")
return False
return True
def test_exchange_user_connection(self) -> bool:
"""Test Exchange Online API connection using user authentication and raise exception if it fails."""
result = self.execute("Connect-ExchangeOnline -Credential $credential")
if "https://aka.ms/exov3-module" not in result:
logger.error(f"Exchange Online User Auth connection failed: {result}.")
return False
connection = self.execute("Get-OrganizationConfig")
if not connection:
logger.error(
"Exchange Online User Auth connection failed: Please check your permissions and try again."
)
return False
return True
def connect_microsoft_teams(self) -> dict:
"""
Connect to Microsoft Teams Module PowerShell Module.
Establishes a connection to Microsoft Teams using the initialized credentials.
Supports three authentication methods:
1. User authentication (username/password)
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
Returns:
dict: Connection status information in JSON format.
@@ -277,26 +399,15 @@ class M365PowerShell(PowerShellSession):
Note:
This method requires the Microsoft Teams PowerShell module to be installed.
"""
# Certificate Auth
if self.execute("Write-Output $certificate") != "":
return self.test_teams_certificate_connection()
# User Auth
if self.execute("Write-Output $credential") != "":
self.execute("Connect-MicrosoftTeams -Credential $credential")
# Test connection with a simple call
connection = self.execute("Get-CsTeamsClientConfiguration")
if connection:
return True
else:
logger.error(
"Microsoft Teams User Auth connection failed: Please check your permissions and try again."
)
return connection
return self.test_teams_user_connection()
# Application Auth
else:
connection = self.test_teams_connection()
if connection:
self.execute(
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
)
return connection
return self.test_teams_connection()
def get_teams_settings(self) -> dict:
"""
@@ -383,6 +494,10 @@ class M365PowerShell(PowerShellSession):
Connect to Exchange Online PowerShell Module.
Establishes a connection to Exchange Online using the initialized credentials.
Supports three authentication methods:
1. User authentication (username/password)
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
Returns:
dict: Connection status information in JSON format.
@@ -390,25 +505,15 @@ class M365PowerShell(PowerShellSession):
Note:
This method requires the Exchange Online PowerShell module to be installed.
"""
# Certificate Auth
if self.execute("Write-Output $certificate") != "":
return self.test_exchange_certificate_connection()
# User Auth
if self.execute("Write-Output $credential") != "":
self.execute("Connect-ExchangeOnline -Credential $credential")
connection = self.execute("Get-OrganizationConfig")
if connection:
return True
else:
logger.error(
"Exchange Online User Auth connection failed: Please check your permissions and try again."
)
return False
return self.test_exchange_user_connection()
# Application Auth
else:
connection = self.test_exchange_connection()
if connection:
self.execute(
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"'
)
return connection
return self.test_exchange_connection()
def get_audit_log_config(self) -> dict:
"""
+306 -49
View File
@@ -1,4 +1,5 @@
import asyncio
import base64
import os
from argparse import ArgumentTypeError
from os import getenv
@@ -6,6 +7,7 @@ from uuid import UUID
from azure.core.exceptions import ClientAuthenticationError, HttpResponseError
from azure.identity import (
CertificateCredential,
ClientSecretCredential,
CredentialUnavailableError,
DefaultAzureCredential,
@@ -41,6 +43,8 @@ from prowler.providers.m365.exceptions.exceptions import (
M365MissingEnvironmentCredentialsError,
M365NoAuthenticationMethodError,
M365NotTenantIdButClientIdAndClientSecretError,
M365NotValidCertificateContentError,
M365NotValidCertificatePathError,
M365NotValidClientIdError,
M365NotValidClientSecretError,
M365NotValidTenantIdError,
@@ -111,11 +115,14 @@ class M365Provider(Provider):
env_auth: bool = False,
az_cli_auth: bool = False,
browser_auth: bool = False,
certificate_auth: bool = False,
tenant_id: str = None,
client_id: str = None,
client_secret: str = None,
user: str = None,
password: str = None,
certificate_content: str = None,
certificate_path: str = None,
init_modules: bool = False,
region: str = "M365Global",
config_content: dict = None,
@@ -158,11 +165,14 @@ class M365Provider(Provider):
sp_env_auth,
env_auth,
browser_auth,
certificate_auth,
tenant_id,
client_id,
client_secret,
user,
password,
certificate_content,
certificate_path,
)
logger.info("Checking if region is different than default one")
@@ -170,13 +180,15 @@ class M365Provider(Provider):
# Get the dict from the static credentials
m365_credentials = None
if tenant_id and client_id and client_secret:
if tenant_id and client_id:
m365_credentials = self.validate_static_credentials(
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
user=user,
password=password,
certificate_content=certificate_content,
certificate_path=certificate_path,
)
# Set up the M365 session
@@ -185,6 +197,8 @@ class M365Provider(Provider):
sp_env_auth,
env_auth,
browser_auth,
certificate_auth,
certificate_path,
tenant_id,
m365_credentials,
self._region_config,
@@ -196,6 +210,7 @@ class M365Provider(Provider):
env_auth,
browser_auth,
az_cli_auth,
certificate_auth,
self._session,
)
@@ -203,6 +218,8 @@ class M365Provider(Provider):
self._credentials = self.setup_powershell(
env_auth=env_auth,
sp_env_auth=sp_env_auth,
certificate_auth=certificate_auth,
certificate_path=certificate_path,
m365_credentials=m365_credentials,
identity=self.identity,
init_modules=init_modules,
@@ -279,11 +296,14 @@ class M365Provider(Provider):
sp_env_auth: bool,
env_auth: bool,
browser_auth: bool,
certificate_auth: bool,
tenant_id: str,
client_id: str,
client_secret: str,
user: str,
password: str,
certificate_content: str,
certificate_path: str,
):
"""
Validates the authentication arguments for the M365 provider.
@@ -293,11 +313,14 @@ class M365Provider(Provider):
sp_env_auth (bool): Flag indicating whether application authentication with environment variables is enabled.
env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables.
browser_auth (bool): Flag indicating whether browser authentication is enabled.
certificate_auth (bool): Flag indicating whether certificate authentication is enabled.
tenant_id (str): The M365 Tenant ID.
client_id (str): The M365 Client ID.
client_secret (str): The M365 Client Secret.
user (str): The M365 User Account.
password (str): The M365 User Password.
certificate_content (str): The M365 Certificate Content.
certificate_path (str): The path to the certificate file.
Raises:
M365BrowserAuthNoTenantIDError: If browser authentication is enabled but the tenant ID is not found.
@@ -314,28 +337,33 @@ class M365Provider(Provider):
and not sp_env_auth
and not browser_auth
and not env_auth
and not certificate_auth
):
raise M365NoAuthenticationMethodError(
file=os.path.basename(__file__),
message="M365 provider requires at least one authentication method set: [--env-auth | --az-cli-auth | --sp-env-auth | --browser-auth]",
message="M365 provider requires at least one authentication method set: [--env-auth | --az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]",
)
elif browser_auth and not tenant_id:
raise M365BrowserAuthNoTenantIDError(
file=os.path.basename(__file__),
message="M365 Tenant ID (--tenant-id) is required for browser authentication mode",
)
elif env_auth:
if not user or not password or not tenant_id:
raise M365MissingEnvironmentCredentialsError(
file=os.path.basename(__file__),
message="M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_PASSWORD environment variables to be set when using --env-auth",
)
else:
if not tenant_id:
raise M365NotTenantIdButClientIdAndClientSecretError(
file=os.path.basename(__file__),
message="Tenant Id is required for M365 static credentials. Make sure you are using the correct credentials.",
)
if (
not certificate_content
and not certificate_path
and not (user and password)
and not client_secret
):
raise M365ConfigCredentialsError(
file=os.path.basename(__file__),
message="You must provide a valid set of credentials. Please check your credentials and try again.",
)
@staticmethod
def setup_region_config(region):
@@ -378,6 +406,8 @@ class M365Provider(Provider):
def setup_powershell(
env_auth: bool = False,
sp_env_auth: bool = False,
certificate_auth: bool = False,
certificate_path: str = None,
m365_credentials: dict = {},
identity: M365IdentityInfo = None,
init_modules: bool = False,
@@ -402,6 +432,7 @@ class M365Provider(Provider):
client_id=m365_credentials.get("client_id", ""),
client_secret=m365_credentials.get("client_secret", ""),
tenant_id=m365_credentials.get("tenant_id", ""),
certificate_content=m365_credentials.get("certificate_content", ""),
tenant_domains=identity.tenant_domains,
)
elif env_auth:
@@ -440,10 +471,28 @@ class M365Provider(Provider):
tenant_domains=identity.tenant_domains,
)
elif certificate_auth:
client_id = getenv("AZURE_CLIENT_ID")
tenant_id = getenv("AZURE_TENANT_ID")
if certificate_path:
with open(certificate_path, "rb") as cert_file:
# Encode the certificate content to base64 since PowerShell expects a base64 string
certificate_content = base64.b64encode(cert_file.read())
else:
certificate_content = getenv("M365_CERTIFICATE_CONTENT")
credentials = M365Credentials(
client_id=client_id,
tenant_id=tenant_id,
certificate_content=certificate_content,
tenant_domains=identity.tenant_domains,
)
if credentials:
if identity and credentials.user:
identity.user = credentials.user
identity.identity_type = "Service Principal and User Credentials"
if identity and credentials.certificate_content:
identity.identity_type = "Service Principal with Certificate"
test_session = M365PowerShell(credentials, identity)
try:
if init_modules:
@@ -478,6 +527,10 @@ class M365Provider(Provider):
report_lines.append(
f"M365 User: {Fore.YELLOW}{self.credentials.user}{Style.RESET_ALL}"
)
elif self.credentials and self.credentials.certificate_content:
report_lines.append(
f"M365 Certificate Thumbprint: {Fore.YELLOW}{self._identity.certificate_thumbprint}{Style.RESET_ALL}"
)
report_title = (
f"{Style.BRIGHT}Using the M365 credentials below:{Style.RESET_ALL}"
)
@@ -491,6 +544,8 @@ class M365Provider(Provider):
sp_env_auth: bool,
env_auth: bool,
browser_auth: bool,
certificate_auth: bool,
certificate_path: str,
tenant_id: str,
m365_credentials: dict,
region_config: M365RegionConfig,
@@ -510,6 +565,8 @@ class M365Provider(Provider):
- client_secret: The M365 client secret
- user: The M365 user email
- password: The M365 user password
- certificate_content: The M365 certificate content
- certificate_path: The path to the certificate file.
- provider_id: The M365 provider ID (in this case the Tenant ID).
region_config (M365RegionConfig): The region configuration object.
@@ -530,16 +587,45 @@ class M365Provider(Provider):
f"{environment_credentials_error.__class__.__name__}[{environment_credentials_error.__traceback__.tb_lineno}] -- {environment_credentials_error}"
)
raise environment_credentials_error
elif certificate_auth:
try:
M365Provider.check_certificate_creds_env_vars(
check_certificate_content=not certificate_path
)
except M365EnvironmentVariableError as environment_variable_error:
logger.critical(
f"{environment_variable_error.__class__.__name__}[{environment_variable_error.__traceback__.tb_lineno}] -- {environment_variable_error}"
)
raise environment_variable_error
try:
if m365_credentials:
try:
credentials = ClientSecretCredential(
tenant_id=m365_credentials["tenant_id"],
client_id=m365_credentials["client_id"],
client_secret=m365_credentials["client_secret"],
user=m365_credentials["user"],
password=m365_credentials["password"],
)
if m365_credentials["certificate_content"]:
credentials = CertificateCredential(
tenant_id=m365_credentials["tenant_id"],
client_id=m365_credentials["client_id"],
certificate_data=base64.b64decode(
m365_credentials["certificate_content"]
),
)
elif m365_credentials["certificate_path"]:
with open(
m365_credentials["certificate_path"], "rb"
) as cert_file:
certificate_data = cert_file.read()
credentials = CertificateCredential(
tenant_id=m365_credentials["tenant_id"],
client_id=m365_credentials["client_id"],
certificate_data=certificate_data,
)
else:
credentials = ClientSecretCredential(
tenant_id=m365_credentials["tenant_id"],
client_id=m365_credentials["client_id"],
client_secret=m365_credentials["client_secret"],
user=m365_credentials["user"],
password=m365_credentials["password"],
)
return credentials
except ClientAuthenticationError as error:
logger.error(
@@ -562,13 +648,34 @@ class M365Provider(Provider):
raise M365ConfigCredentialsError(
file=os.path.basename(__file__), original_exception=error
)
elif certificate_auth:
try:
if certificate_path:
with open(certificate_path, "rb") as cert_file:
certificate_data = cert_file.read()
else:
certificate_data = base64.b64decode(
getenv("M365_CERTIFICATE_CONTENT")
)
credentials = CertificateCredential(
tenant_id=getenv("AZURE_TENANT_ID"),
client_id=getenv("AZURE_CLIENT_ID"),
certificate_data=certificate_data,
)
except ClientAuthenticationError as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
raise M365ClientAuthenticationError(
file=os.path.basename(__file__), original_exception=error
)
else:
# Since the authentication method to be used will come as True, we have to negate it since
# DefaultAzureCredential sets just one authentication method, excluding the others
try:
credentials = DefaultAzureCredential(
exclude_environment_credential=not (
sp_env_auth or env_auth
sp_env_auth or env_auth or certificate_auth
),
exclude_cli_credential=not az_cli_auth,
# M365 Auth using Managed Identity is not supported
@@ -633,6 +740,7 @@ class M365Provider(Provider):
sp_env_auth: bool = False,
env_auth: bool = False,
browser_auth: bool = False,
certificate_auth: bool = False,
tenant_id: str = None,
region: str = "M365Global",
raise_on_exception: bool = True,
@@ -640,6 +748,8 @@ class M365Provider(Provider):
client_secret: str = None,
user: str = None,
password: str = None,
certificate_content: str = None,
certificate_path: str = None,
provider_id: str = None,
) -> Connection:
"""Test connection to M365 tenant and PowerShell modules.
@@ -652,6 +762,7 @@ class M365Provider(Provider):
sp_env_auth (bool): Flag indicating whether to use application authentication with environment variables.
env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables.
browser_auth (bool): Flag indicating whether to use interactive browser authentication.
certificate_auth (bool): Flag indicating whether to use certificate authentication.
tenant_id (str): The M365 Active Directory tenant ID.
region (str): The M365 region.
raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails.
@@ -688,11 +799,14 @@ class M365Provider(Provider):
sp_env_auth,
env_auth,
browser_auth,
certificate_auth,
tenant_id,
client_id,
client_secret,
user,
password,
certificate_content,
certificate_path,
)
region_config = M365Provider.setup_region_config(region)
@@ -704,8 +818,6 @@ class M365Provider(Provider):
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
user=None,
password=None,
)
else:
m365_credentials = M365Provider.validate_static_credentials(
@@ -715,6 +827,12 @@ class M365Provider(Provider):
user=user,
password=password,
)
elif tenant_id and client_id and certificate_content:
m365_credentials = M365Provider.validate_static_credentials(
tenant_id=tenant_id,
client_id=client_id,
certificate_content=certificate_content,
)
# Set up the M365 session
session = M365Provider.setup_session(
@@ -722,6 +840,8 @@ class M365Provider(Provider):
sp_env_auth,
env_auth,
browser_auth,
certificate_auth,
certificate_path,
tenant_id,
m365_credentials,
region_config,
@@ -737,6 +857,7 @@ class M365Provider(Provider):
env_auth,
browser_auth,
az_cli_auth,
certificate_auth,
session,
)
@@ -758,6 +879,8 @@ class M365Provider(Provider):
M365Provider.setup_powershell(
env_auth,
sp_env_auth,
certificate_auth,
certificate_path,
m365_credentials,
identity,
)
@@ -889,12 +1012,44 @@ class M365Provider(Provider):
message=f"Missing environment variable {env_var} required to authenticate.",
)
@staticmethod
def check_certificate_creds_env_vars(check_certificate_content: bool):
"""
Checks the presence of required environment variables for service principal authentication against Azure.
This method checks for the presence of the following environment variables:
- AZURE_CLIENT_ID: Azure client ID
- AZURE_TENANT_ID: Azure tenant ID
- M365_CERTIFICATE_CONTENT: Azure certificate content
If any of the environment variables is missing, it logs a critical error and exits the program.
"""
logger.info(
"M365 provider: checking service principal environment variables ..."
)
env_vars = [
"AZURE_CLIENT_ID",
"AZURE_TENANT_ID",
]
if check_certificate_content:
env_vars.append("M365_CERTIFICATE_CONTENT")
for env_var in env_vars:
if not getenv(env_var):
logger.critical(
f"M365 provider: Missing environment variable {env_var} needed to authenticate against M365."
)
raise M365EnvironmentVariableError(
file=os.path.basename(__file__),
message=f"Missing environment variable {env_var} required to authenticate.",
)
@staticmethod
def setup_identity(
sp_env_auth,
env_auth,
browser_auth,
az_cli_auth,
certificate_auth,
session,
):
"""
@@ -968,6 +1123,16 @@ class M365Provider(Provider):
or session.credentials[0]._credential.client_id
or "Unknown user id (Missing AAD permissions)"
)
elif certificate_auth:
identity.identity_type = "Service Principal with Certificate"
identity.identity_id = (
getenv("AZURE_CLIENT_ID")
or session.credentials[0]._credential.client_id
or "Unknown user id (Missing AAD permissions)"
)
identity.certificate_thumbprint = session._client_credential.get(
"thumbprint", "Unknown certificate thumbprint"
)
elif browser_auth or az_cli_auth:
identity.identity_type = "User"
try:
@@ -987,8 +1152,14 @@ class M365Provider(Provider):
)
else:
# Static Credentials
identity.identity_type = "Service Principal"
identity.identity_id = session._client_id
if isinstance(session, CertificateCredential):
identity.identity_type = "Service Principal with Certificate"
identity.certificate_thumbprint = session._client_credential.get(
"thumbprint", "Unknown certificate thumbprint"
)
else:
identity.identity_type = "Service Principal"
# Retrieve tenant id from the client
client = GraphServiceClient(credentials=session)
@@ -1005,6 +1176,8 @@ class M365Provider(Provider):
client_secret: str = None,
user: str = None,
password: str = None,
certificate_content: str = None,
certificate_path: str = None,
) -> dict:
"""
Validates the static credentials for the M365 provider.
@@ -1015,6 +1188,8 @@ class M365Provider(Provider):
client_secret (str): The M365 client secret.
user (str): The M365 user email.
password (str): The M365 user password.
certificate_content (str): The M365 Certificate Content.
certificate_path (str): The path to the certificate file.
Raises:
M365NotValidTenantIdError: If the provided M365 Tenant ID is not valid.
@@ -1045,21 +1220,47 @@ class M365Provider(Provider):
message="The provided Client ID is not valid.",
)
# Validate the Client Secret
if not client_secret:
if not certificate_content and not certificate_path and not client_secret:
raise M365NotValidClientSecretError(
file=os.path.basename(__file__),
message="The provided Client Secret is not valid.",
message="You must provide a client secret, certificate content or certificate path. Please check your credentials and try again.",
)
if certificate_content:
try:
# Validate that certificate content can be properly decoded from base64
base64.b64decode(certificate_content)
except Exception as e:
raise M365NotValidCertificateContentError(
file=os.path.basename(__file__),
message=f"The provided certificate content is not valid base64 encoded data: {str(e)}",
)
if certificate_path:
try:
with open(certificate_path, "rb") as cert_file:
certificate_content = cert_file.read()
except Exception as e:
raise M365NotValidCertificatePathError(
file=os.path.basename(__file__),
message=f"The provided certificate path is not valid: {str(e)}",
)
try:
M365Provider.verify_client(tenant_id, client_id, client_secret)
M365Provider.verify_client(
tenant_id,
client_id,
client_secret,
certificate_content,
certificate_path,
)
return {
"tenant_id": tenant_id,
"client_id": client_id,
"client_secret": client_secret,
"user": user,
"password": password,
"certificate_content": certificate_content,
"certificate_path": certificate_path,
}
except M365NotValidTenantIdError as tenant_id_error:
logger.error(
@@ -1087,7 +1288,9 @@ class M365Provider(Provider):
)
@staticmethod
def verify_client(tenant_id, client_id, client_secret) -> None:
def verify_client(
tenant_id, client_id, client_secret, certificate_content, certificate_path
) -> None:
"""
Verifies the M365 client credentials using the specified tenant ID, client ID, and client secret.
@@ -1095,52 +1298,106 @@ class M365Provider(Provider):
tenant_id (str): The M365 Active Directory tenant ID.
client_id (str): The M365 client ID.
client_secret (str): The M365 client secret.
certificate_content (str): The M365 certificate content.
certificate_path (str): The path to the certificate file.
Raises:
M365NotValidTenantIdError: If the provided M365 Tenant ID is not valid.
M365NotValidClientIdError: If the provided M365 Client ID is not valid.
M365NotValidClientSecretError: If the provided M365 Client Secret is not valid.
M365NotValidCertificateContentError: If the provided M365 Certificate Content is not valid.
M365NotValidCertificatePathError: If the provided M365 Certificate Path is not valid.
Returns:
None
"""
authority = f"https://login.microsoftonline.com/{tenant_id}"
try:
# Create a ConfidentialClientApplication instance
app = ConfidentialClientApplication(
client_id=client_id,
client_credential=client_secret,
authority=authority,
)
if client_secret:
# Create a ConfidentialClientApplication instance
app = ConfidentialClientApplication(
client_id=client_id,
client_credential=client_secret,
authority=authority,
)
# Attempt to acquire a token
result = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
# Attempt to acquire a token
result = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
# Check if token acquisition was successful
if "access_token" not in result:
# Handle specific errors based on the MSAL response
error_description = result.get("error_description", "")
if f"Tenant '{tenant_id}'" in error_description:
raise M365NotValidTenantIdError(
file=os.path.basename(__file__),
message="The provided Tenant ID is not valid for the specified Client ID and Client Secret.",
)
if (
f"Application with identifier '{client_id}'"
in error_description
):
raise M365NotValidClientIdError(
file=os.path.basename(__file__),
message="The provided Client ID is not valid for the specified Tenant ID and Client Secret.",
)
if "Invalid client secret provided" in error_description:
raise M365NotValidClientSecretError(
file=os.path.basename(__file__),
message="The provided Client Secret is not valid for the specified Tenant ID and Client ID.",
)
elif certificate_content:
credential = CertificateCredential(
client_id=client_id,
tenant_id=tenant_id,
certificate_data=base64.b64decode(certificate_content),
)
client = GraphServiceClient(credentials=credential)
# Check if token acquisition was successful
if "access_token" not in result:
# Handle specific errors based on the MSAL response
error_description = result.get("error_description", "")
if f"Tenant '{tenant_id}'" in error_description:
raise M365NotValidTenantIdError(
# Verify that the certificate is valid
async def verify_certificate():
result = await client.domains.get()
return result.value
result = asyncio.get_event_loop().run_until_complete(
verify_certificate()
)
if not result:
raise M365NotValidCertificateContentError(
file=os.path.basename(__file__),
message="The provided Tenant ID is not valid for the specified Client ID and Client Secret.",
message="The provided certificate content is not valid.",
)
if f"Application with identifier '{client_id}'" in error_description:
raise M365NotValidClientIdError(
elif certificate_path:
with open(certificate_path, "rb") as cert_file:
certificate_content = cert_file.read()
credential = CertificateCredential(
client_id=client_id,
tenant_id=tenant_id,
certificate_data=certificate_content,
)
client = GraphServiceClient(credentials=credential)
# Verify that the certificate is valid
async def verify_certificate():
result = await client.domains.get()
return result.value
result = asyncio.get_event_loop().run_until_complete(
verify_certificate()
)
if not result:
raise M365NotValidCertificatePathError(
file=os.path.basename(__file__),
message="The provided Client ID is not valid for the specified Tenant ID and Client Secret.",
)
if "Invalid client secret provided" in error_description:
raise M365NotValidClientSecretError(
file=os.path.basename(__file__),
message="The provided Client Secret is not valid for the specified Tenant ID and Client ID.",
message="The provided certificate is not valid.",
)
except (
M365NotValidTenantIdError,
M365NotValidClientIdError,
M365NotValidClientSecretError,
M365NotValidCertificateContentError,
M365NotValidCertificatePathError,
) as m365_error:
# M365 specific errors already raised
raise RuntimeError(f"{m365_error}")
+3 -1
View File
@@ -11,6 +11,7 @@ class M365IdentityInfo(BaseModel):
identity_type: str = ""
tenant_id: str = ""
tenant_domain: str = "Unknown tenant domain (missing Entra permissions)"
certificate_thumbprint: str = ""
tenant_domains: list[str] = []
location: str = ""
user: str = None
@@ -28,9 +29,10 @@ class M365Credentials(BaseModel):
passwd: Optional[str] = None
encrypted_passwd: Optional[str] = None
client_id: str = ""
client_secret: str = ""
client_secret: Optional[str] = None
tenant_id: str = ""
tenant_domains: list[str] = []
certificate_content: Optional[str] = None
class M365OutputOptions(ProviderOutputOptions):
@@ -87,9 +87,11 @@ class AdminCenter(M365Service):
{
user.id: User(
id=user.id,
name=user.display_name,
name=getattr(user, "display_name", ""),
license=(
license_details.value[0].sku_part_number
getattr(
license_details.value[0], "sku_part_number", None
)
if license_details.value
else None
),
@@ -149,8 +151,8 @@ class AdminCenter(M365Service):
{
group.id: Group(
id=group.id,
name=group.display_name,
visibility=group.visibility,
name=getattr(group, "display_name", ""),
visibility=getattr(group, "visibility", ""),
)
}
)
@@ -168,14 +170,21 @@ class AdminCenter(M365Service):
domains_list = await self.client.domains.get()
domains.update({})
for domain in domains_list.value:
domains.update(
{
domain.id: Domain(
id=domain.id,
password_validity_period=domain.password_validity_period_in_days,
)
}
)
if domain:
password_validity_period = getattr(
domain, "password_validity_period_in_days", None
)
if password_validity_period is None:
password_validity_period = 0
domains.update(
{
domain.id: Domain(
id=domain.id,
password_validity_period=password_validity_period,
)
}
)
except Exception as error:
logger.error(
@@ -166,4 +166,4 @@ class defender_antispam_outbound_policy_forwarding_disabled(Check):
policy.default
or defender_client.outbound_spam_rules[policy.name].state.lower()
== "enabled"
) and not policy.auto_forwarding_mode
) and policy.auto_forwarding_mode == "Off"
@@ -44,6 +44,23 @@ class Defender(M365Service):
malware_policy = [malware_policy]
for policy in malware_policy:
if policy:
file_types_raw = policy.get("FileTypes", [])
file_types = []
if file_types_raw is not None:
if isinstance(file_types_raw, list):
file_types = file_types_raw
else:
try:
if isinstance(file_types_raw, str):
file_types = [file_types_raw]
else:
file_types = [str(file_types_raw)]
except (ValueError, TypeError):
logger.warning(
f"Skipping invalid file_types value: {file_types_raw}"
)
file_types = []
malware_policies.append(
MalwarePolicy(
enable_file_filter=policy.get("EnableFileFilter", False),
@@ -54,7 +71,7 @@ class Defender(M365Service):
internal_sender_admin_address=policy.get(
"InternalSenderAdminAddress", ""
),
file_types=policy.get("FileTypes", []),
file_types=file_types,
is_default=policy.get("IsDefault", False),
)
)
@@ -207,7 +224,7 @@ class Defender(M365Service):
notify_sender_blocked_addresses=policy.get(
"NotifyOutboundSpamRecipients", []
),
auto_forwarding_mode=policy.get("AutoForwardingMode", True),
auto_forwarding_mode=policy.get("AutoForwardingMode", "On"),
default=policy.get("IsDefault", False),
)
@@ -257,12 +274,43 @@ class Defender(M365Service):
inbound_spam_policy = [inbound_spam_policy]
for policy in inbound_spam_policy:
if policy:
allowed_domains_raw = policy.get("AllowedSenderDomains", [])
allowed_domains = []
if isinstance(allowed_domains_raw, str):
try:
import json
parsed_domains = json.loads(allowed_domains_raw)
if isinstance(parsed_domains, list):
allowed_domains_raw = parsed_domains
else:
logger.warning(
f"Expected list from JSON string, got: {type(parsed_domains)}"
)
allowed_domains_raw = []
except (json.JSONDecodeError, ValueError) as e:
logger.warning(
f"Failed to parse AllowedSenderDomains as JSON: {e}"
)
allowed_domains_raw = []
if allowed_domains_raw:
for domain in allowed_domains_raw:
if isinstance(domain, str):
allowed_domains.append(domain)
else:
try:
allowed_domains.append(str(domain))
except (ValueError, TypeError):
logger.warning(
f"Skipping invalid domain value: {domain}"
)
inbound_spam_policies.append(
DefenderInboundSpamPolicy(
identity=policy.get("Identity", ""),
allowed_sender_domains=policy.get(
"AllowedSenderDomains", []
),
allowed_sender_domains=allowed_domains,
default=policy.get("IsDefault", False),
)
)
@@ -389,7 +437,7 @@ class OutboundSpamPolicy(BaseModel):
notify_limit_exceeded: bool
notify_limit_exceeded_addresses: List[str]
notify_sender_blocked_addresses: List[str]
auto_forwarding_mode: bool
auto_forwarding_mode: str
default: bool
@@ -123,12 +123,20 @@ class Exchange(M365Service):
rules_data = [rules_data]
for rule in rules_data:
if rule:
sender_domain_is = rule.get("SenderDomainIs", [])
if sender_domain_is is None:
sender_domain_is = []
redirect_message_to = rule.get("RedirectMessageTo", [])
if redirect_message_to is None:
redirect_message_to = []
transport_rules.append(
TransportRule(
name=rule.get("Name", ""),
scl=rule.get("SetSCL", None),
sender_domain_is=rule.get("SenderDomainIs", []),
redirect_message_to=rule.get("RedirectMessageTo", None),
sender_domain_is=sender_domain_is,
redirect_message_to=redirect_message_to,
)
)
except Exception as error:
+3 -1
View File
@@ -585,7 +585,9 @@ class TestFinding:
provider = MagicMock()
provider.type = "github"
# GitHub App identity only has app_id, not account_name/account_id
provider.identity = GithubAppIdentityInfo(app_id=APP_ID)
provider.identity = GithubAppIdentityInfo(
app_id=APP_ID, installations=["test-org"]
)
provider.auth_method = "GitHub App Token"
# Mock check result
+1 -1
View File
@@ -673,7 +673,7 @@ class TestHTML:
provider = set_mocked_github_provider(
auth_method="GitHub App Token",
identity=GithubAppIdentityInfo(app_id=APP_ID),
identity=GithubAppIdentityInfo(app_id=APP_ID, installations=["test-org"]),
)
summary = output.get_assessment_summary(provider)
@@ -13,5 +13,4 @@ class Test_Scan_Filters:
assert not is_resource_filtered(
"arn:aws:iam::123456789012:user/test1", audit_resources
)
assert is_resource_filtered("test_bucket", audit_resources)
assert is_resource_filtered("arn:aws:s3:::test_bucket", audit_resources)
@@ -0,0 +1,122 @@
from unittest import mock
from prowler.providers.aws.services.eks.eks_service import EKSCluster
from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1
cluster_name = "cluster_test"
cluster_arn = (
f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:cluster/{cluster_name}"
)
class Test_eks_cluster_deletion_protection_enabled:
def test_no_clusters(self):
eks_client = mock.MagicMock
eks_client.clusters = []
with mock.patch(
"prowler.providers.aws.services.eks.eks_service.EKS",
eks_client,
):
from prowler.providers.aws.services.eks.eks_cluster_deletion_protection_enabled.eks_cluster_deletion_protection_enabled import (
eks_cluster_deletion_protection_enabled,
)
check = eks_cluster_deletion_protection_enabled()
result = check.execute()
assert len(result) == 0
def test_cluster_deletion_protection_disabled(self):
eks_client = mock.MagicMock
eks_client.clusters = []
eks_client.clusters.append(
EKSCluster(
name=cluster_name,
arn=cluster_arn,
region=AWS_REGION_EU_WEST_1,
deletion_protection=False,
)
)
with mock.patch(
"prowler.providers.aws.services.eks.eks_service.EKS",
eks_client,
):
from prowler.providers.aws.services.eks.eks_cluster_deletion_protection_enabled.eks_cluster_deletion_protection_enabled import (
eks_cluster_deletion_protection_enabled,
)
check = eks_cluster_deletion_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} has deletion protection disabled."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION_EU_WEST_1
def test_cluster_deletion_protection_enabled(self):
eks_client = mock.MagicMock
eks_client.clusters = []
eks_client.clusters.append(
EKSCluster(
name=cluster_name,
arn=cluster_arn,
region=AWS_REGION_EU_WEST_1,
deletion_protection=True,
)
)
with mock.patch(
"prowler.providers.aws.services.eks.eks_service.EKS",
eks_client,
):
from prowler.providers.aws.services.eks.eks_cluster_deletion_protection_enabled.eks_cluster_deletion_protection_enabled import (
eks_cluster_deletion_protection_enabled,
)
check = eks_cluster_deletion_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} has deletion protection enabled."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION_EU_WEST_1
def test_cluster_deletion_protection_none(self):
eks_client = mock.MagicMock
eks_client.clusters = []
eks_client.clusters.append(
EKSCluster(
name=cluster_name,
arn=cluster_arn,
region=AWS_REGION_EU_WEST_1,
deletion_protection=None,
)
)
with mock.patch(
"prowler.providers.aws.services.eks.eks_service.EKS",
eks_client,
):
from prowler.providers.aws.services.eks.eks_cluster_deletion_protection_enabled.eks_cluster_deletion_protection_enabled import (
eks_cluster_deletion_protection_enabled,
)
check = eks_cluster_deletion_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} has deletion protection enabled."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION_EU_WEST_1
@@ -362,17 +362,16 @@ class Test_iam_inline_policy_allows_privilege_escalation:
check = iam_inline_policy_allows_privilege_escalation()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "PASS"
assert result[0].resource_id == f"test_role/{policy_name}"
assert result[0].resource_arn == role_arn
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_tags == []
assert search(
f"Inline policy {policy_name} attached to role {role_name} allows privilege escalation using the following actions: ",
f"Inline policy {policy_name} attached to role {role_name} does not allow privilege escalation",
result[0].status_extended,
)
assert search("iam:PassRole", result[0].status_extended)
@mock_aws
def test_iam_inline_policy_allows_privilege_escalation_two_combinations(
@@ -511,17 +510,16 @@ class Test_iam_inline_policy_allows_privilege_escalation:
check = iam_inline_policy_allows_privilege_escalation()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "PASS"
assert result[0].resource_id == f"test_role/{policy_name}"
assert result[0].resource_arn == role_arn
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_tags == []
assert search(
f"Inline policy {policy_name} attached to role {role_name} allows privilege escalation using the following actions: ",
f"Inline policy {policy_name} attached to role {role_name} does not allow privilege escalation",
result[0].status_extended,
)
assert search("iam:PassRole", result[0].status_extended)
@mock_aws
def test_iam_inline_policy_allows_privilege_escalation_policies_combination(
@@ -1219,3 +1217,75 @@ class Test_iam_inline_policy_allows_privilege_escalation:
f"Inline Policy '{policy_name}' attached to role {role_arn} allows privilege escalation using the following actions:",
finding.status_extended,
)
@mock_aws
def test_iam_inline_role_policy_allows_privilege_escalation_agentcore(self):
"""Test detection of AWS Bedrock AgentCore privilege escalation in inline role policy."""
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
# Create IAM Role
role_name = "agentcore_test_role"
role_arn = iam_client.create_role(
RoleName=role_name,
AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY),
)["Role"]["Arn"]
# Put Role Policy with AgentCore privilege escalation permissions
policy_name = "agentcore_policy"
policy_document = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iam:PassRole",
"bedrock-agentcore:CreateCodeInterpreter",
"bedrock-agentcore:InvokeCodeInterpreter",
],
"Resource": "*",
}
],
}
_ = iam_client.put_role_policy(
RoleName=role_name,
PolicyName=policy_name,
PolicyDocument=dumps(policy_document),
)
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
from prowler.providers.aws.services.iam.iam_service import IAM
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.iam.iam_inline_policy_allows_privilege_escalation.iam_inline_policy_allows_privilege_escalation.iam_client",
new=IAM(aws_provider),
),
):
# Test Check
from prowler.providers.aws.services.iam.iam_inline_policy_allows_privilege_escalation.iam_inline_policy_allows_privilege_escalation import (
iam_inline_policy_allows_privilege_escalation,
)
check = iam_inline_policy_allows_privilege_escalation()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == f"{role_name}/{policy_name}"
assert result[0].resource_arn == role_arn
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_tags == []
assert search(
f"Inline policy {policy_name} attached to role {role_name} allows privilege escalation using the following actions: ",
result[0].status_extended,
)
assert search("iam:PassRole", result[0].status_extended)
assert search(
"bedrock-agentcore:CreateCodeInterpreter", result[0].status_extended
)
assert search(
"bedrock-agentcore:InvokeCodeInterpreter", result[0].status_extended
)
@@ -322,17 +322,16 @@ class Test_iam_policy_allows_privilege_escalation:
check = iam_policy_allows_privilege_escalation()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "PASS"
assert result[0].resource_id == policy_name
assert result[0].resource_arn == policy_arn
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_tags == []
assert search(
f"Custom Policy {policy_arn} allows privilege escalation using the following actions: ",
f"Custom Policy {policy_arn} does not allow privilege escalation",
result[0].status_extended,
)
assert search("iam:PassRole", result[0].status_extended)
@mock_aws
def test_iam_policy_allows_privilege_escalation_iam_PassRole_using_wildcard(
@@ -375,17 +374,16 @@ class Test_iam_policy_allows_privilege_escalation:
check = iam_policy_allows_privilege_escalation()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "PASS"
assert result[0].resource_id == policy_name
assert result[0].resource_arn == policy_arn
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_tags == []
assert search(
f"Custom Policy {policy_arn} allows privilege escalation using the following actions: ",
f"Custom Policy {policy_arn} does not allow privilege escalation",
result[0].status_extended,
)
assert search("iam:PassRole", result[0].status_extended)
@mock_aws
def test_iam_policy_allows_privilege_escalation_two_combinations(
@@ -508,17 +506,16 @@ class Test_iam_policy_allows_privilege_escalation:
check = iam_policy_allows_privilege_escalation()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "PASS"
assert result[0].resource_id == policy_name
assert result[0].resource_arn == policy_arn
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_tags == []
assert search(
f"Custom Policy {policy_arn} allows privilege escalation using the following actions: ",
f"Custom Policy {policy_arn} does not allow privilege escalation",
result[0].status_extended,
)
assert search("iam:PassRole", result[0].status_extended)
@mock_aws
def test_iam_policy_allows_privilege_escalation_policies_combination(
@@ -915,6 +912,71 @@ class Test_iam_policy_allows_privilege_escalation:
]:
assert search(permission, finding.status_extended)
@mock_aws
def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_invoke(
self,
):
"""Test detection of AWS Bedrock AgentCore privilege escalation pattern."""
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
policy_name = "agentcore_privilege_escalation_policy"
policy_document = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iam:PassRole",
"bedrock-agentcore:CreateCodeInterpreter",
"bedrock-agentcore:InvokeCodeInterpreter",
],
"Resource": "*",
}
],
}
policy_arn = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
)["Policy"]["Arn"]
from prowler.providers.aws.services.iam.iam_service import IAM
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client",
new=IAM(aws_provider),
),
):
# Test Check
from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import (
iam_policy_allows_privilege_escalation,
)
check = iam_policy_allows_privilege_escalation()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == policy_name
assert result[0].resource_arn == policy_arn
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_tags == []
assert search(
f"Custom Policy {policy_arn} allows privilege escalation using the following actions: ",
result[0].status_extended,
)
assert search("iam:PassRole", result[0].status_extended)
assert search(
"bedrock-agentcore:CreateCodeInterpreter", result[0].status_extended
)
assert search(
"bedrock-agentcore:InvokeCodeInterpreter", result[0].status_extended
)
@mock_aws
def test_iam_policy_allows_privilege_escalation_iam_put(
self,
@@ -52,7 +52,6 @@ class Test_PrivilegeEscalation:
assert "iam:Put*" in result
assert "iam:AddUserToGroup" in result
assert "iam:AttachRolePolicy" in result
assert "iam:PassRole" in result
assert "iam:CreateLoginProfile" in result
assert "iam:CreateAccessKey" in result
assert "iam:AttachGroupPolicy" in result
@@ -78,9 +77,9 @@ class Test_PrivilegeEscalation:
],
}
result = check_privilege_escalation(policy)
assert "iam:PassRole" in result
assert result == ""
def test_check_privilege_escalation_priv_escalation_iam_PassRole_using_wildcard(
def test_check_privilege_escalation_priv_escalation_iam_wildcard(
self,
):
policy = {
@@ -88,13 +87,16 @@ class Test_PrivilegeEscalation:
"Statement": [
{
"Effect": "Allow",
"Action": ["iam:*Role"], # Should expand to include PassRole
"Action": [
"iam:*"
], # Should expand to include multiple IAM actions
"Resource": ["*"],
}
],
}
result = check_privilege_escalation(policy)
assert "iam:PassRole" in result
# iam:* should expand to include PutUserPolicy and other privilege escalation actions
assert "iam:PutUserPolicy" in result
def test_check_privilege_escalation_priv_escalation_not_action(
self,
@@ -117,7 +119,6 @@ class Test_PrivilegeEscalation:
assert "'iam:PutGroupPolicy'" not in result
assert "iam:AddUserToGroup" in result
assert "iam:AttachRolePolicy" in result
assert "iam:PassRole" in result
assert "iam:CreateLoginProfile" in result
assert "iam:CreateAccessKey" in result
assert "iam:AttachGroupPolicy" in result
@@ -72,10 +72,10 @@ class Test_kafka_cluster_is_public:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Kafka cluster 'demo-cluster-1' is publicly accessible."
== "Kafka cluster demo-cluster-1 is not publicly accessible."
)
assert (
result[0].resource_arn
@@ -126,10 +126,10 @@ class Test_kafka_cluster_is_public:
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Kafka cluster 'demo-cluster-1' is not publicly accessible."
== "Kafka cluster demo-cluster-1 is publicly accessible."
)
assert (
result[0].resource_arn
@@ -85,6 +85,7 @@ class TestAzureProvider:
"python_latest_version": "3.12",
"java_latest_version": "17",
"recommended_minimal_tls_versions": ["1.2", "1.3"],
"vm_backup_min_daily_retention_days": 7,
"desired_vm_sku_sizes": [
"Standard_A8_v2",
"Standard_DS3_v2",
@@ -136,26 +136,32 @@ class Test_app_http_logs_enabled:
logs=[
mock.MagicMock(
category="AppServiceHTTPLogs",
category_group=None,
enabled=True,
),
mock.MagicMock(
category="AppServiceConsoleLogs",
category_group=None,
enabled=False,
),
mock.MagicMock(
category="AppServiceAppLogs",
category_group=None,
enabled=True,
),
mock.MagicMock(
category="AppServiceAuditLogs",
category_group=None,
enabled=False,
),
mock.MagicMock(
category="AppServiceIPSecAuditLogs",
category_group=None,
enabled=False,
),
mock.MagicMock(
category="AppServicePlatformLogs",
category_group=None,
enabled=False,
),
],
@@ -181,26 +187,32 @@ class Test_app_http_logs_enabled:
logs=[
mock.MagicMock(
category="AppServiceHTTPLogs",
category_group=None,
enabled=True,
),
mock.MagicMock(
category="AppServiceConsoleLogs",
category_group=None,
enabled=True,
),
mock.MagicMock(
category="AppServiceAppLogs",
category_group=None,
enabled=True,
),
mock.MagicMock(
category="AppServiceAuditLogs",
category_group=None,
enabled=False,
),
mock.MagicMock(
category="AppServiceIPSecAuditLogs",
category_group=None,
enabled=True,
),
mock.MagicMock(
category="AppServicePlatformLogs",
category_group=None,
enabled=False,
),
],
@@ -223,3 +235,129 @@ class Test_app_http_logs_enabled:
result[0].status_extended
== f"App app_id-2 has HTTP Logs enabled in diagnostic setting name_diagnostic_setting2 in subscription {AZURE_SUBSCRIPTION_ID}"
)
def test_diagnostic_setting_with_all_logs_category_group(self):
app_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.app.app_http_logs_enabled.app_http_logs_enabled.app_client",
new=app_client,
),
):
from prowler.providers.azure.services.app.app_http_logs_enabled.app_http_logs_enabled import (
app_http_logs_enabled,
)
from prowler.providers.azure.services.app.app_service import WebApp
from prowler.providers.azure.services.monitor.monitor_service import (
DiagnosticSetting,
)
app_client.apps = {
AZURE_SUBSCRIPTION_ID: {
"resource_id3": WebApp(
resource_id="resource_id3",
name="app_id-3",
auth_enabled=True,
configurations=None,
client_cert_mode="Ignore",
https_only=False,
kind="WebApp",
identity=mock.MagicMock,
location="West Europe",
monitor_diagnostic_settings=[
DiagnosticSetting(
id="id3/id3",
logs=[
mock.MagicMock(
category=None,
category_group="allLogs",
enabled=True,
),
],
storage_account_name="storage_account_name3",
storage_account_id="storage_account_id3",
name="name_diagnostic_setting3",
),
],
),
}
}
check = app_http_logs_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == "app_id-3"
assert result[0].resource_id == "resource_id3"
assert (
result[0].status_extended
== f"App app_id-3 has allLogs category group which includes HTTP Logs enabled in diagnostic setting name_diagnostic_setting3 in subscription {AZURE_SUBSCRIPTION_ID}"
)
def test_diagnostic_setting_with_all_logs_category_group_disabled(self):
app_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.app.app_http_logs_enabled.app_http_logs_enabled.app_client",
new=app_client,
),
):
from prowler.providers.azure.services.app.app_http_logs_enabled.app_http_logs_enabled import (
app_http_logs_enabled,
)
from prowler.providers.azure.services.app.app_service import WebApp
from prowler.providers.azure.services.monitor.monitor_service import (
DiagnosticSetting,
)
app_client.apps = {
AZURE_SUBSCRIPTION_ID: {
"resource_id4": WebApp(
resource_id="resource_id4",
name="app_id-4",
auth_enabled=True,
configurations=None,
client_cert_mode="Ignore",
https_only=False,
kind="WebApp",
identity=mock.MagicMock,
location="West Europe",
monitor_diagnostic_settings=[
DiagnosticSetting(
id="id4/id4",
logs=[
mock.MagicMock(
category=None,
category_group="allLogs",
enabled=False, # Disabled
),
],
storage_account_name="storage_account_name4",
storage_account_id="storage_account_id4",
name="name_diagnostic_setting4",
),
],
),
}
}
check = app_http_logs_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == "app_id-4"
assert result[0].resource_id == "resource_id4"
assert (
result[0].status_extended
== f"App app_id-4 does not have HTTP Logs enabled in diagnostic setting name_diagnostic_setting4 in subscription {AZURE_SUBSCRIPTION_ID}"
)
@@ -6,6 +6,7 @@ from prowler.providers.azure.services.defender.defender_service import (
AutoProvisioningSetting,
Defender,
IoTSecuritySolution,
JITPolicy,
Pricing,
SecurityContactConfiguration,
Setting,
@@ -103,6 +104,19 @@ def mock_defender_get_iot_security_solutions(_):
}
def mock_defender_get_jit_policies(_):
return {
AZURE_SUBSCRIPTION_ID: {
"policy-1": JITPolicy(
id="policy-1",
name="JITPolicy1",
location="eastus",
vm_ids=["vm-1", "vm-2"],
)
}
}
@patch(
"prowler.providers.azure.services.defender.defender_service.Defender._get_pricings",
new=mock_defender_get_pricings,
@@ -127,6 +141,10 @@ def mock_defender_get_iot_security_solutions(_):
"prowler.providers.azure.services.defender.defender_service.Defender._get_iot_security_solutions",
new=mock_defender_get_iot_security_solutions,
)
@patch(
"prowler.providers.azure.services.defender.defender_service.Defender._get_jit_policies",
new=mock_defender_get_jit_policies,
)
class Test_Defender_Service:
def test_get_client(self):
defender = Defender(set_mocked_azure_provider())
@@ -255,3 +273,13 @@ class Test_Defender_Service:
].status
== "Enabled"
)
def test_get_jit_policies(self):
defender = Defender(set_mocked_azure_provider())
assert AZURE_SUBSCRIPTION_ID in defender.jit_policies
assert "policy-1" in defender.jit_policies[AZURE_SUBSCRIPTION_ID]
policy1 = defender.jit_policies[AZURE_SUBSCRIPTION_ID]["policy-1"]
assert policy1.id == "policy-1"
assert policy1.name == "JITPolicy1"
assert policy1.location == "eastus"
assert set(policy1.vm_ids) == {"vm-1", "vm-2"}
@@ -4,7 +4,6 @@ from uuid import uuid4
from prowler.providers.azure.services.storage.storage_service import (
Account,
NetworkRuleSet,
ReplicationSettings,
)
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
@@ -35,10 +34,11 @@ class Test_storage_geo_redundant_enabled:
result = check.execute()
assert len(result) == 0
def test_storage_geo_redundant_enabled(self):
def test_storage_account_standard_grs_enabled(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account GRS"
storage_client = mock.MagicMock()
replication_setting = "Standard_GRS"
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
@@ -56,7 +56,7 @@ class Test_storage_geo_redundant_enabled:
private_endpoint_connections=[],
key_expiration_period_in_days=None,
location="westeurope",
replication_settings=ReplicationSettings.STANDARD_GRS,
replication_settings=replication_setting,
)
]
}
@@ -81,17 +81,18 @@ class Test_storage_geo_redundant_enabled:
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage (GRS) enabled."
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage {replication_setting} enabled."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == storage_account_name
assert result[0].resource_id == storage_account_id
assert result[0].location == "westeurope"
def test_storage_account_geo_redundant_disabled(self):
def test_storage_account_standard_ragrs_enabled(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account LRS"
storage_account_name = "Test Storage Account RAGRS"
storage_client = mock.MagicMock()
replication_setting = "Standard_RAGRS"
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
@@ -109,7 +110,169 @@ class Test_storage_geo_redundant_enabled:
private_endpoint_connections=[],
key_expiration_period_in_days=None,
location="westeurope",
replication_settings=ReplicationSettings.STANDARD_LRS,
replication_settings=replication_setting,
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client",
new=storage_client,
),
):
from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import (
storage_geo_redundant_enabled,
)
check = storage_geo_redundant_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage {replication_setting} enabled."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == storage_account_name
assert result[0].resource_id == storage_account_id
assert result[0].location == "westeurope"
def test_storage_account_standard_gzrs_enabled(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account GZRS"
storage_client = mock.MagicMock()
replication_setting = "Standard_GZRS"
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id=storage_account_id,
name=storage_account_name,
resouce_group_name="rg",
enable_https_traffic_only=False,
infrastructure_encryption=False,
allow_blob_public_access=False,
network_rule_set=NetworkRuleSet(
bypass="AzureServices", default_action="Allow"
),
encryption_type="None",
minimum_tls_version="TLS1_2",
private_endpoint_connections=[],
key_expiration_period_in_days=None,
location="westeurope",
replication_settings=replication_setting,
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client",
new=storage_client,
),
):
from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import (
storage_geo_redundant_enabled,
)
check = storage_geo_redundant_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage {replication_setting} enabled."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == storage_account_name
assert result[0].resource_id == storage_account_id
assert result[0].location == "westeurope"
def test_storage_account_standard_ragzrs_enabled(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account RAGZRS"
storage_client = mock.MagicMock()
replication_setting = "Standard_RAGZRS"
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id=storage_account_id,
name=storage_account_name,
resouce_group_name="rg",
enable_https_traffic_only=False,
infrastructure_encryption=False,
allow_blob_public_access=False,
network_rule_set=NetworkRuleSet(
bypass="AzureServices", default_action="Allow"
),
encryption_type="None",
minimum_tls_version="TLS1_2",
private_endpoint_connections=[],
key_expiration_period_in_days=None,
location="westeurope",
replication_settings=replication_setting,
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client",
new=storage_client,
),
):
from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import (
storage_geo_redundant_enabled,
)
check = storage_geo_redundant_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage {replication_setting} enabled."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == storage_account_name
assert result[0].resource_id == storage_account_id
assert result[0].location == "westeurope"
def test_storage_account_standard_lrs_disabled(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account LRS"
storage_client = mock.MagicMock()
replication_setting = "Standard_LRS"
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id=storage_account_id,
name=storage_account_name,
resouce_group_name="rg",
enable_https_traffic_only=False,
infrastructure_encryption=False,
allow_blob_public_access=False,
network_rule_set=NetworkRuleSet(
bypass="AzureServices", default_action="Allow"
),
encryption_type="None",
minimum_tls_version="TLS1_2",
private_endpoint_connections=[],
key_expiration_period_in_days=None,
location="westeurope",
replication_settings=replication_setting,
)
]
}
@@ -134,7 +297,169 @@ class Test_storage_geo_redundant_enabled:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage (GRS) enabled."
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage enabled, it has {replication_setting} instead."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == storage_account_name
assert result[0].resource_id == storage_account_id
assert result[0].location == "westeurope"
def test_storage_account_standard_zrs_disabled(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account ZRS"
storage_client = mock.MagicMock()
replication_setting = "Standard_ZRS"
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id=storage_account_id,
name=storage_account_name,
resouce_group_name="rg",
enable_https_traffic_only=False,
infrastructure_encryption=False,
allow_blob_public_access=False,
network_rule_set=NetworkRuleSet(
bypass="AzureServices", default_action="Allow"
),
encryption_type="None",
minimum_tls_version="TLS1_2",
private_endpoint_connections=[],
key_expiration_period_in_days=None,
location="westeurope",
replication_settings=replication_setting,
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client",
new=storage_client,
),
):
from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import (
storage_geo_redundant_enabled,
)
check = storage_geo_redundant_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage enabled, it has {replication_setting} instead."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == storage_account_name
assert result[0].resource_id == storage_account_id
assert result[0].location == "westeurope"
def test_storage_account_premium_lrs_disabled(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account Premium LRS"
storage_client = mock.MagicMock()
replication_setting = "Premium_LRS"
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id=storage_account_id,
name=storage_account_name,
resouce_group_name="rg",
enable_https_traffic_only=False,
infrastructure_encryption=False,
allow_blob_public_access=False,
network_rule_set=NetworkRuleSet(
bypass="AzureServices", default_action="Allow"
),
encryption_type="None",
minimum_tls_version="TLS1_2",
private_endpoint_connections=[],
key_expiration_period_in_days=None,
location="westeurope",
replication_settings=replication_setting,
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client",
new=storage_client,
),
):
from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import (
storage_geo_redundant_enabled,
)
check = storage_geo_redundant_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage enabled, it has {replication_setting} instead."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == storage_account_name
assert result[0].resource_id == storage_account_id
assert result[0].location == "westeurope"
def test_storage_account_premium_zrs_disabled(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account Premium ZRS"
storage_client = mock.MagicMock()
replication_setting = "Premium_ZRS"
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id=storage_account_id,
name=storage_account_name,
resouce_group_name="rg",
enable_https_traffic_only=False,
infrastructure_encryption=False,
allow_blob_public_access=False,
network_rule_set=NetworkRuleSet(
bypass="AzureServices", default_action="Allow"
),
encryption_type="None",
minimum_tls_version="TLS1_2",
private_endpoint_connections=[],
key_expiration_period_in_days=None,
location="westeurope",
replication_settings=replication_setting,
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client",
new=storage_client,
),
):
from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import (
storage_geo_redundant_enabled,
)
check = storage_geo_redundant_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage enabled, it has {replication_setting} instead."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == storage_account_name
@@ -6,7 +6,6 @@ from prowler.providers.azure.services.storage.storage_service import (
DeleteRetentionPolicy,
FileServiceProperties,
NetworkRuleSet,
ReplicationSettings,
SMBProtocolSettings,
Storage,
)
@@ -53,7 +52,7 @@ def mock_storage_get_storage_accounts(_):
location="westeurope",
blob_properties=blob_properties,
default_to_entra_authorization=True,
replication_settings=ReplicationSettings.STANDARD_LRS,
replication_settings="Standard_LRS",
allow_cross_tenant_replication=True,
allow_shared_key_access=True,
file_service_properties=file_service_properties,
@@ -150,7 +149,7 @@ class Test_Storage_Service:
].default_to_entra_authorization
assert (
storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].replication_settings
== ReplicationSettings.STANDARD_LRS
== "Standard_LRS"
)
assert (
storage.storage_accounts[AZURE_SUBSCRIPTION_ID][
@@ -0,0 +1,290 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.azure.services.defender.defender_service import JITPolicy
from prowler.providers.azure.services.vm.vm_service import VirtualMachine
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
set_mocked_azure_provider,
)
class Test_vm_jit_access_enabled:
def test_no_subscriptions(self):
vm_client = mock.MagicMock()
vm_client.virtual_machines = {}
defender_client = mock.MagicMock()
defender_client.jit_policies = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled import (
vm_jit_access_enabled,
)
check = vm_jit_access_enabled()
result = check.execute()
assert result == []
def test_no_vms(self):
vm_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}}
defender_client = mock.MagicMock()
defender_client.jit_policies = {AZURE_SUBSCRIPTION_ID: {}}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled import (
vm_jit_access_enabled,
)
check = vm_jit_access_enabled()
result = check.execute()
assert result == []
def test_vm_with_jit_enabled(self):
vm_id = str(uuid4())
vm_name = "TestVM"
vm_location = "eastus"
vm = VirtualMachine(
resource_id=vm_id,
resource_name=vm_name,
location=vm_location,
security_profile=None,
extensions=[],
storage_profile=None,
)
vm_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}}
defender_client = mock.MagicMock()
jit_policy = JITPolicy(
id="policy1",
name="JITPolicy1",
location="eastus",
vm_ids={vm_id},
)
defender_client.jit_policies = {
AZURE_SUBSCRIPTION_ID: {jit_policy.id: jit_policy}
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled import (
vm_jit_access_enabled,
)
check = vm_jit_access_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_id == vm_id
assert result[0].resource_name == vm_name
assert "has JIT (Just-in-Time) access enabled" in result[0].status_extended
def test_vm_with_jit_disabled(self):
vm_id = str(uuid4())
vm_name = "TestVM"
vm_location = "eastus"
vm = VirtualMachine(
resource_id=vm_id,
resource_name=vm_name,
location=vm_location,
security_profile=None,
extensions=[],
storage_profile=None,
)
vm_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}}
defender_client = mock.MagicMock()
# JIT policy does not include this VM
jit_policy = JITPolicy(
id="policy1",
name="JITPolicy1",
location="eastus",
vm_ids={"some-other-id"},
)
defender_client.jit_policies = {
AZURE_SUBSCRIPTION_ID: {jit_policy.id: jit_policy}
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled import (
vm_jit_access_enabled,
)
check = vm_jit_access_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_id == vm_id
assert result[0].resource_name == vm_name
assert (
"does not have JIT (Just-in-Time) access enabled"
in result[0].status_extended
)
def test_vm_id_case_insensitivity(self):
vm_id = str(uuid4())
vm_name = "TestVM"
vm_location = "eastus"
upper_vm_id = vm_id.upper()
vm = VirtualMachine(
resource_id=upper_vm_id,
resource_name=vm_name,
location=vm_location,
security_profile=None,
extensions=[],
storage_profile=None,
)
vm_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {upper_vm_id: vm}}
defender_client = mock.MagicMock()
jit_policy = JITPolicy(
id="policy1",
name="JITPolicy1",
location="eastus",
vm_ids={vm_id.lower()},
)
defender_client.jit_policies = {
AZURE_SUBSCRIPTION_ID: {jit_policy.id: jit_policy}
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled import (
vm_jit_access_enabled,
)
check = vm_jit_access_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == upper_vm_id
assert "has JIT (Just-in-Time) access enabled" in result[0].status_extended
def test_multiple_vms_and_policies(self):
vm_id_1 = str(uuid4())
vm_id_2 = str(uuid4())
vm1 = VirtualMachine(
resource_id=vm_id_1,
resource_name="VM1",
location="eastus",
security_profile=None,
extensions=[],
storage_profile=None,
)
vm2 = VirtualMachine(
resource_id=vm_id_2,
resource_name="VM2",
location="eastus",
security_profile=None,
extensions=[],
storage_profile=None,
)
vm_client = mock.MagicMock()
vm_client.virtual_machines = {
AZURE_SUBSCRIPTION_ID: {vm_id_1: vm1, vm_id_2: vm2}
}
defender_client = mock.MagicMock()
jit_policy_1 = JITPolicy(
id="policy1",
name="JITPolicy1",
location="eastus",
vm_ids={vm_id_1},
)
jit_policy_2 = JITPolicy(
id="policy2",
name="JITPolicy2",
location="eastus",
vm_ids=set(),
)
defender_client.jit_policies = {
AZURE_SUBSCRIPTION_ID: {
jit_policy_1.id: jit_policy_1,
jit_policy_2.id: jit_policy_2,
}
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.azure.services.vm.vm_jit_access_enabled.vm_jit_access_enabled import (
vm_jit_access_enabled,
)
check = vm_jit_access_enabled()
result = check.execute()
assert len(result) == 2
for r in result:
if r.resource_id == vm_id_1:
assert r.status == "PASS"
elif r.resource_id == vm_id_2:
assert r.status == "FAIL"
@@ -0,0 +1,323 @@
from unittest import mock
from uuid import uuid4
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
set_mocked_azure_provider,
)
class Test_vm_sufficient_daily_backup_retention_period:
def test_no_subscriptions(self):
vm_client = mock.MagicMock()
recovery_client = mock.MagicMock()
vm_client.virtual_machines = {}
recovery_client.vaults = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.recovery_client",
new=recovery_client,
),
):
from prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period import (
vm_sufficient_daily_backup_retention_period,
)
check = vm_sufficient_daily_backup_retention_period()
result = check.execute()
assert len(result) == 0
def test_no_vms(self):
vm_client = mock.MagicMock()
recovery_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}}
recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {}}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.recovery_client",
new=recovery_client,
),
):
from prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period import (
vm_sufficient_daily_backup_retention_period,
)
check = vm_sufficient_daily_backup_retention_period()
result = check.execute()
assert len(result) == 0
def test_vm_with_sufficient_retention(self):
from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType
from prowler.providers.azure.services.recovery.recovery_service import (
BackupItem,
BackupPolicy,
BackupVault,
)
from prowler.providers.azure.services.vm.vm_service import (
ManagedDiskParameters,
OSDisk,
StorageProfile,
VirtualMachine,
)
vm_id = str(uuid4())
vm_name = "VMTest"
vault_id = str(uuid4())
policy_id = str(uuid4())
retention_days = 14
min_retention_days = 7
vm = VirtualMachine(
resource_id=vm_id,
resource_name=vm_name,
location="eastus",
security_profile=None,
extensions=[],
storage_profile=StorageProfile(
os_disk=OSDisk(
name="os_disk_name",
operating_system_type="Linux",
managed_disk=ManagedDiskParameters(id="managed_disk_id"),
),
data_disks=[],
),
)
backup_item = BackupItem(
id=str(uuid4()),
name=f"someprefix;{vm_name}",
workload_type=DataSourceType.VM,
backup_policy_id=policy_id,
)
backup_policy = BackupPolicy(
id=policy_id,
name="policy1",
retention_days=retention_days,
)
vault = BackupVault(
id=vault_id,
name="vault1",
location="eastus",
backup_protected_items={backup_item.id: backup_item},
backup_policies={policy_id: backup_policy},
)
vm_client = mock.MagicMock()
recovery_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}}
recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}}
vm_client.audit_config = {
"vm_backup_min_daily_retention_days": min_retention_days
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(
audit_config=vm_client.audit_config
),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.recovery_client",
new=recovery_client,
),
):
from prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period import (
vm_sufficient_daily_backup_retention_period,
)
check = vm_sufficient_daily_backup_retention_period()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == vm_name
assert result[0].resource_id == vm_id
assert (
f"has a daily backup retention period of {retention_days} days"
in result[0].status_extended
)
def test_vm_with_insufficient_retention(self):
from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType
from prowler.providers.azure.services.recovery.recovery_service import (
BackupItem,
BackupPolicy,
BackupVault,
)
from prowler.providers.azure.services.vm.vm_service import (
ManagedDiskParameters,
OSDisk,
StorageProfile,
VirtualMachine,
)
vm_id = str(uuid4())
vm_name = "VMTest"
vault_id = str(uuid4())
policy_id = str(uuid4())
retention_days = 3
min_retention_days = 7
vm = VirtualMachine(
resource_id=vm_id,
resource_name=vm_name,
location="eastus",
security_profile=None,
extensions=[],
storage_profile=StorageProfile(
os_disk=OSDisk(
name="os_disk_name",
operating_system_type="Linux",
managed_disk=ManagedDiskParameters(id="managed_disk_id"),
),
data_disks=[],
),
)
backup_item = BackupItem(
id=str(uuid4()),
name=f"someprefix;{vm_name}",
workload_type=DataSourceType.VM,
backup_policy_id=policy_id,
)
backup_policy = BackupPolicy(
id=policy_id,
name="policy1",
retention_days=retention_days,
)
vault = BackupVault(
id=vault_id,
name="vault1",
location="eastus",
backup_protected_items={backup_item.id: backup_item},
backup_policies={policy_id: backup_policy},
)
vm_client = mock.MagicMock()
recovery_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}}
recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}}
vm_client.audit_config = {
"vm_backup_min_daily_retention_days": min_retention_days
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(
audit_config=vm_client.audit_config
),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.recovery_client",
new=recovery_client,
),
):
from prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period import (
vm_sufficient_daily_backup_retention_period,
)
check = vm_sufficient_daily_backup_retention_period()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == vm_name
assert result[0].resource_id == vm_id
assert (
f"has insufficient daily backup retention period of {retention_days} days"
in result[0].status_extended
)
def test_vm_with_no_backup_policy(self):
from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType
from prowler.providers.azure.services.recovery.recovery_service import (
BackupItem,
BackupVault,
)
from prowler.providers.azure.services.vm.vm_service import (
ManagedDiskParameters,
OSDisk,
StorageProfile,
VirtualMachine,
)
vm_id = str(uuid4())
vm_name = "VMTest"
vault_id = str(uuid4())
vm = VirtualMachine(
resource_id=vm_id,
resource_name=vm_name,
location="eastus",
security_profile=None,
extensions=[],
storage_profile=StorageProfile(
os_disk=OSDisk(
name="os_disk_name",
operating_system_type="Linux",
managed_disk=ManagedDiskParameters(id="managed_disk_id"),
),
data_disks=[],
),
)
backup_item = BackupItem(
id=str(uuid4()),
name=f"someprefix;{vm_name}",
workload_type=DataSourceType.VM,
backup_policy_id=None,
)
vault = BackupVault(
id=vault_id,
name="vault1",
location="eastus",
backup_protected_items={backup_item.id: backup_item},
backup_policies={},
)
vm_client = mock.MagicMock()
recovery_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}}
recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.recovery_client",
new=recovery_client,
),
):
from prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period import (
vm_sufficient_daily_backup_retention_period,
)
check = vm_sufficient_daily_backup_retention_period()
result = check.execute()
assert len(result) == 0
@@ -175,7 +175,7 @@ class Test_compute_project_os_login_enabled:
result[0].status_extended,
)
assert result[0].resource_id == project.id
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].location == "global"
assert result[0].project_id == GCP_PROJECT_ID
@@ -225,6 +225,6 @@ class Test_compute_project_os_login_enabled:
result[0].status_extended,
)
assert result[0].resource_id == project.id
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].location == "global"
assert result[0].project_id == GCP_PROJECT_ID
@@ -44,6 +44,7 @@ class Test_iam_account_access_approval_enabled:
result[0].status_extended,
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == "test"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == "global"
@@ -95,5 +96,58 @@ class Test_iam_account_access_approval_enabled:
result[0].status_extended,
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == "test"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == "global"
def test_iam_project_with_settings_empty_project_name(self):
cloudresourcemanager_client = mock.MagicMock()
accessapproval_client = mock.MagicMock()
accessapproval_client.project_ids = [GCP_PROJECT_ID]
accessapproval_client.region = "global"
accessapproval_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="",
labels={},
lifecycle_state="ACTIVE",
)
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled.accessapproval_client",
new=accessapproval_client,
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_service.cloudresourcemanager_client",
new=cloudresourcemanager_client,
),
):
from prowler.providers.gcp.services.iam.iam_service import Setting
accessapproval_client.settings = {
GCP_PROJECT_ID: Setting(name="test", project_id=GCP_PROJECT_ID)
}
from prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled import (
iam_account_access_approval_enabled,
)
check = iam_account_access_approval_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert search(
"has Access Approval enabled",
result[0].status_extended,
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == "global"
@@ -176,7 +176,7 @@ class Test_iam_audit_logs_enabled:
r.status_extended,
)
assert r.resource_id == GCP_PROJECT_ID
assert r.resource_name == GCP_PROJECT_ID
assert r.resource_name == "GCP Project"
assert r.project_id == GCP_PROJECT_ID
assert r.location == cloudresourcemanager_client.region
@@ -226,6 +226,6 @@ class Test_iam_audit_logs_enabled:
r.status_extended,
)
assert r.resource_id == GCP_PROJECT_ID
assert r.resource_name == GCP_PROJECT_ID
assert r.resource_name == "GCP Project"
assert r.project_id == GCP_PROJECT_ID
assert r.location == cloudresourcemanager_client.region
@@ -113,7 +113,7 @@ class Test_iam_no_service_roles_at_project_level:
result[0].status_extended,
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "test"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == cloudresourcemanager_client.region
@@ -255,6 +255,6 @@ class Test_iam_no_service_roles_at_project_level:
result[0].status_extended,
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == cloudresourcemanager_client.region
@@ -213,7 +213,7 @@ class Test_iam_role_kms_enforce_separation_of_duties:
r.status_extended,
)
assert r.resource_id == GCP_PROJECT_ID
assert r.resource_name == GCP_PROJECT_ID
assert r.resource_name == "GCP Project"
assert r.project_id == GCP_PROJECT_ID
assert r.location == cloudresourcemanager_client.region
@@ -277,6 +277,6 @@ class Test_iam_role_kms_enforce_separation_of_duties:
r.status_extended,
)
assert r.resource_id == GCP_PROJECT_ID
assert r.resource_name == GCP_PROJECT_ID
assert r.resource_name == "GCP Project"
assert r.project_id == GCP_PROJECT_ID
assert r.location == cloudresourcemanager_client.region
@@ -213,7 +213,7 @@ class Test_iam_role_sa_enforce_separation_of_duties:
r.status_extended,
)
assert r.resource_id == GCP_PROJECT_ID
assert r.resource_name == GCP_PROJECT_ID
assert r.resource_name == "GCP Project"
assert r.project_id == GCP_PROJECT_ID
assert r.location == cloudresourcemanager_client.region
@@ -277,6 +277,6 @@ class Test_iam_role_sa_enforce_separation_of_duties:
r.status_extended,
)
assert r.resource_id == GCP_PROJECT_ID
assert r.resource_name == GCP_PROJECT_ID
assert r.resource_name == "GCP Project"
assert r.project_id == GCP_PROJECT_ID
assert r.location == cloudresourcemanager_client.region
@@ -141,7 +141,7 @@ class Test_logging_log_metric_filter_and_alert_for_audit_configuration_changes_e
== f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
@@ -141,7 +141,7 @@ class Test_logging_log_metric_filter_and_alert_for_bucket_permission_changes_ena
== f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
@@ -141,7 +141,7 @@ class Test_logging_log_metric_filter_and_alert_for_custom_role_changes_enabled:
== f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
@@ -141,7 +141,7 @@ class Test_logging_log_metric_filter_and_alert_for_project_ownership_changes_ena
== f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
@@ -141,7 +141,7 @@ class Test_logging_log_metric_filter_and_alert_for_sql_instance_configuration_ch
== f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
@@ -141,7 +141,7 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_ena
== f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
@@ -141,7 +141,7 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled:
== f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
@@ -141,7 +141,7 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_route_changes_ena
== f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
@@ -204,7 +204,7 @@ class Test_logging_sink_created:
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == GCP_PROJECT_ID
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
assert (
@@ -135,6 +135,7 @@ class TestGitHubProvider:
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
return_value=GithubAppIdentityInfo(
app_id=APP_ID,
installations=["test-org"],
),
),
):
@@ -147,7 +148,9 @@ class TestGitHubProvider:
assert provider._type == "github"
assert provider.session == GithubSession(token="", id=APP_ID, key=APP_KEY)
assert provider.identity == GithubAppIdentityInfo(app_id=APP_ID)
assert provider.identity == GithubAppIdentityInfo(
app_id=APP_ID, installations=["test-org"]
)
assert provider._audit_config == {
"inactive_not_archived_days_threshold": 180,
}
@@ -206,7 +209,9 @@ class TestGitHubProvider:
),
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
return_value=GithubAppIdentityInfo(app_id=APP_ID),
return_value=GithubAppIdentityInfo(
app_id=APP_ID, installations=["test-org"]
),
),
):
connection = GithubProvider.test_connection(
@@ -1,6 +1,7 @@
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import requests
from github import GithubException, RateLimitExceededException
from prowler.providers.github.services.repository.repository_service import (
@@ -40,6 +41,7 @@ def mock_list_repositories(_):
archived=False,
pushed_at=datetime.now(timezone.utc),
delete_branch_on_merge=True,
dependabot_alerts_enabled=True,
),
}
@@ -110,6 +112,105 @@ class Test_Repository_FileExists:
assert mock_logger.error.called
class Test_Repository_GraphQL:
def setup_method(self):
self.mock_repo1 = MagicMock()
self.mock_repo1.id = 1
self.mock_repo1.name = "repo1"
self.mock_repo1.owner.login = "owner1"
self.mock_repo1.full_name = "owner1/repo1"
self.mock_repo1.default_branch = "main"
self.mock_repo1.private = False
self.mock_repo1.archived = False
self.mock_repo1.pushed_at = datetime.now(timezone.utc)
self.mock_repo1.delete_branch_on_merge = False
self.mock_repo1.security_and_analysis = None
self.mock_repo1.get_contents.side_effect = [None, None, None]
self.mock_repo1.get_branch.side_effect = Exception("404 Not Found")
self.mock_repo1.get_dependabot_alerts.side_effect = Exception("403 Forbidden")
def test_no_scoping_uses_graphql(self):
"""Test that no scoping triggers the GraphQL discovery method successfully"""
provider = set_mocked_github_provider()
provider.repositories = []
provider.organizations = []
with patch.object(Repository, "__init__", lambda x, y: None):
repository_service = Repository(provider)
mock_client = MagicMock()
repository_service.clients = [mock_client]
repository_service.provider = provider
with patch.object(
repository_service,
"_get_accessible_repos_graphql",
return_value=["owner1/repo1"],
) as mock_graphql_call:
mock_client.get_repo.return_value = self.mock_repo1
repos = repository_service._list_repositories()
assert len(repos) == 1
assert 1 in repos
assert repos[1].name == "repo1"
mock_graphql_call.assert_called_once()
mock_client.get_repo.assert_called_once_with("owner1/repo1")
def test_graphql_call_api_error(self):
"""Test that an error during the GraphQL call is handled gracefully"""
provider = set_mocked_github_provider()
provider.repositories = []
provider.organizations = []
with patch.object(Repository, "__init__", lambda x, y: None):
repository_service = Repository(provider)
repository_service.clients = [MagicMock()]
repository_service.provider = provider
with patch(
"requests.post",
side_effect=requests.exceptions.RequestException("API Error"),
):
with patch(
"prowler.providers.github.services.repository.repository_service.logger"
) as mock_logger:
repos = repository_service._list_repositories()
assert len(repos) == 0
mock_logger.error.assert_called_once()
log_output = str(mock_logger.error.call_args)
assert "RequestException" in log_output
assert "API Error" in log_output
def test_graphql_returns_empty_list(self):
"""Test the case where GraphQL returns no repositories"""
provider = set_mocked_github_provider()
provider.repositories = []
provider.organizations = []
with patch.object(Repository, "__init__", lambda x, y: None):
repository_service = Repository(provider)
repository_service.clients = [MagicMock()]
repository_service.provider = provider
with patch.object(
repository_service, "_get_accessible_repos_graphql", return_value=[]
):
with patch(
"prowler.providers.github.services.repository.repository_service.logger"
) as mock_logger:
repos = repository_service._list_repositories()
assert len(repos) == 0
mock_logger.warning.assert_called_with(
"Could not find any accessible repositories with the provided token."
)
class Test_Repository_Scoping:
def setup_method(self):
self.mock_repo1 = MagicMock()
@@ -123,7 +224,7 @@ class Test_Repository_Scoping:
self.mock_repo1.pushed_at = datetime.now(timezone.utc)
self.mock_repo1.delete_branch_on_merge = True
self.mock_repo1.security_and_analysis = None
self.mock_repo1.get_contents.return_value = None
self.mock_repo1.get_contents.side_effect = [None, None, None]
self.mock_repo1.get_branch.side_effect = Exception("404 Not Found")
self.mock_repo1.get_dependabot_alerts.side_effect = Exception("404 Not Found")
@@ -138,200 +239,10 @@ class Test_Repository_Scoping:
self.mock_repo2.pushed_at = datetime.now(timezone.utc)
self.mock_repo2.delete_branch_on_merge = True
self.mock_repo2.security_and_analysis = None
self.mock_repo2.get_contents.return_value = None
self.mock_repo2.get_contents.side_effect = [None, None, None]
self.mock_repo2.get_branch.side_effect = Exception("404 Not Found")
self.mock_repo2.get_dependabot_alerts.side_effect = Exception("404 Not Found")
def test_no_repository_scoping(self):
"""Test that all repositories are returned when no scoping is specified"""
provider = set_mocked_github_provider()
provider.repositories = []
provider.organizations = []
mock_client = MagicMock()
mock_user = MagicMock()
mock_user.get_repos.return_value = [self.mock_repo1, self.mock_repo2]
mock_client.get_user.return_value = mock_user
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
repos = repository_service._list_repositories()
assert len(repos) == 2
assert 1 in repos
assert 2 in repos
assert repos[1].name == "repo1"
assert repos[2].name == "repo2"
def test_specific_repository_scoping(self):
"""Test that only specified repositories are returned"""
provider = set_mocked_github_provider()
provider.repositories = ["owner1/repo1"]
provider.organizations = []
mock_client = MagicMock()
mock_client.get_repo.return_value = self.mock_repo1
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
repos = repository_service._list_repositories()
assert len(repos) == 1
assert 1 in repos
assert repos[1].name == "repo1"
assert repos[1].full_name == "owner1/repo1"
mock_client.get_repo.assert_called_once_with("owner1/repo1")
def test_multiple_repository_scoping(self):
"""Test that multiple specified repositories are returned"""
provider = set_mocked_github_provider()
provider.repositories = ["owner1/repo1", "owner2/repo2"]
provider.organizations = []
mock_client = MagicMock()
mock_client.get_repo.side_effect = [self.mock_repo1, self.mock_repo2]
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
repos = repository_service._list_repositories()
assert len(repos) == 2
assert 1 in repos
assert 2 in repos
assert repos[1].name == "repo1"
assert repos[2].name == "repo2"
assert mock_client.get_repo.call_count == 2
def test_invalid_repository_format(self):
"""Test that invalid repository formats are skipped with warning"""
provider = set_mocked_github_provider()
provider.repositories = ["invalid-repo-name", "owner/valid-repo"]
provider.organizations = []
mock_client = MagicMock()
mock_client.get_repo.return_value = self.mock_repo1
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
with patch(
"prowler.providers.github.services.repository.repository_service.logger"
) as mock_logger:
repos = repository_service._list_repositories()
# Should only have the valid repository
assert len(repos) == 1
assert 1 in repos
# Should log warning for invalid format
assert mock_logger.warning.call_count >= 1
# Check that at least one warning is about invalid format
warning_calls = [
call[0][0] for call in mock_logger.warning.call_args_list
]
assert any(
"should be in 'owner/repo-name' format" in call
for call in warning_calls
)
def test_repository_not_found(self):
"""Test that inaccessible repositories are skipped with warning"""
provider = set_mocked_github_provider()
provider.repositories = ["owner/nonexistent-repo"]
provider.organizations = []
mock_client = MagicMock()
mock_client.get_repo.side_effect = Exception("404 Not Found")
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
repos = repository_service._list_repositories()
# Should be empty since repository wasn't found
assert len(repos) == 0
def test_organization_scoping(self):
"""Test that repositories from specified organizations are returned"""
provider = set_mocked_github_provider()
provider.repositories = []
provider.organizations = ["org1"]
mock_client = MagicMock()
mock_org = MagicMock()
mock_org.get_repos.return_value = [self.mock_repo1]
mock_client.get_organization.return_value = mock_org
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
repos = repository_service._list_repositories()
assert len(repos) == 1
assert 1 in repos
assert repos[1].name == "repo1"
mock_client.get_organization.assert_called_once_with("org1")
def test_organization_as_user_fallback(self):
"""Test that organization scoping falls back to user when organization not found"""
provider = set_mocked_github_provider()
provider.repositories = []
provider.organizations = ["user1"]
mock_client = MagicMock()
# Organization lookup fails
mock_client.get_organization.side_effect = GithubException(
404, "Not Found", None
)
# User lookup succeeds
mock_user = MagicMock()
mock_user.get_repos.return_value = [self.mock_repo1]
mock_client.get_user.return_value = mock_user
# Create service without calling the parent constructor
repository_service = Repository.__new__(Repository)
repository_service.clients = [mock_client]
repository_service.provider = provider
with patch(
"prowler.providers.github.services.repository.repository_service.logger"
) as mock_logger:
repos = repository_service._list_repositories()
assert len(repos) == 1
assert 1 in repos
assert repos[1].name == "repo1"
mock_client.get_organization.assert_called_once_with("user1")
mock_client.get_user.assert_called_once_with("user1")
# Should log info about trying as user
mock_logger.info.assert_called()
def test_combined_repository_and_organization_scoping(self):
"""Test that both repository and organization scoping can be used together"""
provider = set_mocked_github_provider()
@@ -0,0 +1,641 @@
import argparse
from unittest.mock import MagicMock
from prowler.providers.m365.lib.arguments import arguments
class TestM365Arguments:
def setup_method(self):
"""Setup mock ArgumentParser for testing"""
self.mock_parser = MagicMock()
self.mock_subparsers = MagicMock()
self.mock_m365_parser = MagicMock()
self.mock_auth_group = MagicMock()
self.mock_auth_modes_group = MagicMock()
self.mock_regions_group = MagicMock()
# Setup the mock chain
self.mock_parser.add_subparsers.return_value = self.mock_subparsers
self.mock_subparsers.add_parser.return_value = self.mock_m365_parser
self.mock_m365_parser.add_argument_group.side_effect = [
self.mock_auth_group,
self.mock_regions_group,
]
self.mock_auth_group.add_mutually_exclusive_group.return_value = (
self.mock_auth_modes_group
)
def test_init_parser_creates_subparser(self):
"""Test that init_parser creates the M365 subparser correctly"""
# Create a mock object that has the necessary attributes
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
# Call init_parser
arguments.init_parser(mock_m365_args)
# Verify subparser was created
self.mock_subparsers.add_parser.assert_called_once_with(
"m365",
parents=[mock_m365_args.common_providers_parser],
help="M365 Provider",
)
def test_init_parser_creates_argument_groups(self):
"""Test that init_parser creates the correct argument groups"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify argument groups were created
assert self.mock_m365_parser.add_argument_group.call_count == 2
calls = self.mock_m365_parser.add_argument_group.call_args_list
assert calls[0][0][0] == "Authentication Modes"
assert calls[1][0][0] == "Regions"
def test_init_parser_creates_mutually_exclusive_auth_group(self):
"""Test that init_parser creates mutually exclusive authentication group"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify mutually exclusive group was created for authentication modes
self.mock_auth_group.add_mutually_exclusive_group.assert_called_once()
def test_init_parser_adds_authentication_arguments(self):
"""Test that init_parser adds all authentication arguments"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify authentication arguments were added to the mutually exclusive group
assert self.mock_auth_modes_group.add_argument.call_count == 5
# Check that all authentication arguments are present
calls = self.mock_auth_modes_group.add_argument.call_args_list
auth_args = [call[0][0] for call in calls]
assert "--az-cli-auth" in auth_args
assert "--env-auth" in auth_args
assert "--sp-env-auth" in auth_args
assert "--browser-auth" in auth_args
assert "--certificate-auth" in auth_args
def test_init_parser_adds_non_exclusive_arguments(self):
"""Test that init_parser adds non-exclusive arguments directly to parser"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify non-exclusive arguments were added to main parser
assert self.mock_m365_parser.add_argument.call_count == 3
# Check that non-exclusive arguments are present
calls = self.mock_m365_parser.add_argument.call_args_list
non_exclusive_args = [call[0][0] for call in calls]
assert "--tenant-id" in non_exclusive_args
assert "--init-modules" in non_exclusive_args
assert "--certificate-path" in non_exclusive_args
def test_init_parser_adds_region_arguments(self):
"""Test that init_parser adds region arguments"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify region arguments were added to regions group
assert self.mock_regions_group.add_argument.call_count == 1
# Check that region argument is present
calls = self.mock_regions_group.add_argument.call_args_list
region_args = [call[0][0] for call in calls]
assert "--region" in region_args
def test_az_cli_auth_argument_configuration(self):
"""Test that az-cli-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the az-cli-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
az_cli_call = None
for call in calls:
if call[0][0] == "--az-cli-auth":
az_cli_call = call
break
assert az_cli_call is not None
# Check argument configuration
kwargs = az_cli_call[1]
assert kwargs["action"] == "store_true"
assert "Azure CLI authentication" in kwargs["help"]
def test_env_auth_argument_configuration(self):
"""Test that env-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the env-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
env_auth_call = None
for call in calls:
if call[0][0] == "--env-auth":
env_auth_call = call
break
assert env_auth_call is not None
# Check argument configuration
kwargs = env_auth_call[1]
assert kwargs["action"] == "store_true"
assert "User and Password environment variables" in kwargs["help"]
def test_sp_env_auth_argument_configuration(self):
"""Test that sp-env-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the sp-env-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
sp_env_call = None
for call in calls:
if call[0][0] == "--sp-env-auth":
sp_env_call = call
break
assert sp_env_call is not None
# Check argument configuration
kwargs = sp_env_call[1]
assert kwargs["action"] == "store_true"
assert "Azure Service Principal environment variables" in kwargs["help"]
def test_browser_auth_argument_configuration(self):
"""Test that browser-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the browser-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
browser_auth_call = None
for call in calls:
if call[0][0] == "--browser-auth":
browser_auth_call = call
break
assert browser_auth_call is not None
# Check argument configuration
kwargs = browser_auth_call[1]
assert kwargs["action"] == "store_true"
assert "Azure interactive browser authentication" in kwargs["help"]
def test_certificate_auth_argument_configuration(self):
"""Test that certificate-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the certificate-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
cert_auth_call = None
for call in calls:
if call[0][0] == "--certificate-auth":
cert_auth_call = call
break
assert cert_auth_call is not None
# Check argument configuration
kwargs = cert_auth_call[1]
assert kwargs["action"] == "store_true"
assert "Certificate authentication" in kwargs["help"]
def test_tenant_id_argument_configuration(self):
"""Test that tenant-id argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the tenant-id argument call
calls = self.mock_m365_parser.add_argument.call_args_list
tenant_id_call = None
for call in calls:
if call[0][0] == "--tenant-id":
tenant_id_call = call
break
assert tenant_id_call is not None
# Check argument configuration
kwargs = tenant_id_call[1]
assert kwargs["nargs"] == "?"
assert kwargs["default"] is None
assert "Microsoft 365 Tenant ID" in kwargs["help"]
assert "--browser-auth" in kwargs["help"]
def test_init_modules_argument_configuration(self):
"""Test that init-modules argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the init-modules argument call
calls = self.mock_m365_parser.add_argument.call_args_list
init_modules_call = None
for call in calls:
if call[0][0] == "--init-modules":
init_modules_call = call
break
assert init_modules_call is not None
# Check argument configuration
kwargs = init_modules_call[1]
assert kwargs["action"] == "store_true"
assert "Initialize Microsoft 365 PowerShell modules" in kwargs["help"]
def test_region_argument_configuration(self):
"""Test that region argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the region argument call
calls = self.mock_regions_group.add_argument.call_args_list
region_call = None
for call in calls:
if call[0][0] == "--region":
region_call = call
break
assert region_call is not None
# Check argument configuration
kwargs = region_call[1]
assert kwargs["nargs"] == "?"
assert kwargs["default"] == "M365Global"
assert kwargs["choices"] == [
"M365Global",
"M365GlobalChina",
"M365USGovernment",
]
assert "Microsoft 365 region" in kwargs["help"]
assert "M365Global" in kwargs["help"]
class TestM365ArgumentsIntegration:
def test_real_argument_parsing_az_cli_auth(self):
"""Test parsing arguments with Azure CLI authentication"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
# Create a mock object that mimics the structure used by the init_parser function
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with Azure CLI auth
args = parser.parse_args(["m365", "--az-cli-auth"])
assert args.az_cli_auth is True
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is False
assert args.certificate_auth is False
def test_real_argument_parsing_env_auth(self):
"""Test parsing arguments with environment authentication"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with environment auth
args = parser.parse_args(["m365", "--env-auth"])
assert args.az_cli_auth is False
assert args.env_auth is True
assert args.sp_env_auth is False
assert args.browser_auth is False
assert args.certificate_auth is False
def test_real_argument_parsing_sp_env_auth(self):
"""Test parsing arguments with service principal environment authentication"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with service principal environment auth
args = parser.parse_args(["m365", "--sp-env-auth"])
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is True
assert args.browser_auth is False
assert args.certificate_auth is False
def test_real_argument_parsing_browser_auth_with_tenant_id(self):
"""Test parsing arguments with browser authentication and tenant ID"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with browser auth and tenant ID
args = parser.parse_args(
[
"m365",
"--browser-auth",
"--tenant-id",
"12345678-1234-5678-abcd-123456789012",
]
)
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is True
assert args.certificate_auth is False
assert args.tenant_id == "12345678-1234-5678-abcd-123456789012"
def test_real_argument_parsing_certificate_auth(self):
"""Test parsing arguments with certificate authentication"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with certificate auth
args = parser.parse_args(["m365", "--certificate-auth"])
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is False
assert args.certificate_auth is True
def test_real_argument_parsing_with_init_modules(self):
"""Test parsing arguments with init modules flag"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with init modules
args = parser.parse_args(["m365", "--az-cli-auth", "--init-modules"])
assert args.az_cli_auth is True
assert args.init_modules is True
def test_real_argument_parsing_with_different_regions(self):
"""Test parsing arguments with different region options"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Test M365Global (default)
args = parser.parse_args(["m365", "--az-cli-auth"])
assert args.region == "M365Global"
# Test M365GlobalChina
args = parser.parse_args(
["m365", "--az-cli-auth", "--region", "M365GlobalChina"]
)
assert args.region == "M365GlobalChina"
# Test M365USGovernment
args = parser.parse_args(
["m365", "--az-cli-auth", "--region", "M365USGovernment"]
)
assert args.region == "M365USGovernment"
def test_real_argument_parsing_no_authentication_defaults(self):
"""Test parsing arguments without any authentication flags (should have defaults)"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments without explicit auth (defaults should apply)
args = parser.parse_args(["m365"])
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is False
assert args.certificate_auth is False
assert args.tenant_id is None
assert args.init_modules is False
assert args.region == "M365Global"
def test_real_argument_parsing_complete_configuration(self):
"""Test parsing arguments with all non-exclusive options"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with complete configuration
args = parser.parse_args(
[
"m365",
"--browser-auth",
"--tenant-id",
"12345678-1234-5678-abcd-123456789012",
"--init-modules",
"--region",
"M365USGovernment",
]
)
assert args.browser_auth is True
assert args.tenant_id == "12345678-1234-5678-abcd-123456789012"
assert args.init_modules is True
assert args.region == "M365USGovernment"
def test_mutually_exclusive_authentication_enforcement(self):
"""Test that authentication methods are mutually exclusive"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# This should raise SystemExit due to mutually exclusive group
try:
parser.parse_args(["m365", "--az-cli-auth", "--env-auth"])
assert False, "Expected SystemExit due to mutually exclusive arguments"
except SystemExit:
# This is expected
pass
def test_tenant_id_without_arguments(self):
"""Test that tenant-id can be specified without an argument (optional value)"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with tenant-id but no value (should be None due to nargs="?")
args = parser.parse_args(["m365", "--az-cli-auth", "--tenant-id"])
assert args.tenant_id is None
def test_certificate_path_argument_configuration(self):
"""Test that certificate_path argument is properly configured"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with certificate-path
args = parser.parse_args(
["m365", "--certificate-auth", "--certificate-path", "/path/to/cert.pem"]
)
assert args.certificate_auth is True
assert args.certificate_path == "/path/to/cert.pem"
def test_certificate_path_without_value(self):
"""Test certificate_path argument without value (should be None due to nargs='?')"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with certificate-path but no value
args = parser.parse_args(["m365", "--certificate-auth", "--certificate-path"])
assert args.certificate_auth is True
assert args.certificate_path is None
def test_certificate_auth_with_certificate_path_integration(self):
"""Test certificate authentication with certificate path integration"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse complete certificate authentication arguments
args = parser.parse_args(
[
"m365",
"--certificate-auth",
"--certificate-path",
"/home/user/cert.pem",
"--tenant-id",
"12345678-1234-1234-1234-123456789012",
]
)
assert args.certificate_auth is True
assert args.certificate_path == "/home/user/cert.pem"
assert args.tenant_id == "12345678-1234-1234-1234-123456789012"
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is False
@@ -1,9 +1,11 @@
import base64
from unittest.mock import MagicMock, call, patch
import pytest
from prowler.lib.powershell.powershell import PowerShellSession
from prowler.providers.m365.exceptions.exceptions import (
M365CertificateCreationError,
M365GraphConnectionError,
M365UserCredentialsError,
M365UserNotBelongingToTenantError,
@@ -112,7 +114,7 @@ class Testm365PowerShell:
session.close()
@patch("subprocess.Popen")
def test_test_credentials(self, mock_popen):
def test_test_credentials_exchange_success(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
@@ -131,16 +133,20 @@ class Testm365PowerShell:
tenant_domain="contoso.onmicrosoft.com",
tenant_domains=["contoso.onmicrosoft.com"],
location="test_location",
user="test@contoso.onmicrosoft.com",
)
session = M365PowerShell(credentials, identity)
# Mock encrypt_password to return a known value
session.encrypt_password = MagicMock(return_value="encrypted_password")
# Mock execute to simulate successful Connect-ExchangeOnline
session.execute = MagicMock(
return_value="Connected successfully https://aka.ms/exov3-module"
)
# Mock execute to simulate successful Exchange connection
def mock_execute_side_effect(command):
if "Connect-ExchangeOnline" in command:
return "Connected successfully https://aka.ms/exov3-module"
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
# Execute the test
result = session.test_credentials(credentials)
@@ -153,42 +159,110 @@ class Testm365PowerShell:
session.execute.assert_any_call(
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
)
# Exchange connection should be tested
session.execute.assert_any_call(
"Connect-ExchangeOnline -Credential $credential"
)
# Verify Teams connection was NOT called (since Exchange succeeded)
teams_calls = [
call
for call in session.execute.call_args_list
if "Connect-MicrosoftTeams" in str(call)
]
assert (
len(teams_calls) == 0
), "Teams connection should not be called when Exchange succeeds"
session.close()
@patch("subprocess.Popen")
def test_test_credentials_exchange_fail_teams_success(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials(
user="test@contoso.onmicrosoft.com",
passwd="test_password",
encrypted_passwd="test_encrypted_password",
client_id="test_client_id",
client_secret="test_client_secret",
tenant_id="test_tenant_id",
)
identity = M365IdentityInfo(
identity_id="test_id",
identity_type="User",
tenant_id="test_tenant",
tenant_domain="contoso.onmicrosoft.com",
tenant_domains=["contoso.onmicrosoft.com"],
location="test_location",
user="test@contoso.onmicrosoft.com",
)
session = M365PowerShell(credentials, identity)
# Mock encrypt_password to return a known value
session.encrypt_password = MagicMock(return_value="encrypted_password")
# Mock execute to simulate Exchange fail and Teams success
def mock_execute_side_effect(command):
if "Connect-ExchangeOnline" in command:
return (
"Connection failed" # No "https://aka.ms/exov3-module" in response
)
elif "Connect-MicrosoftTeams" in command:
return "Connected successfully test@contoso.onmicrosoft.com"
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
# Execute the test
result = session.test_credentials(credentials)
assert result is True
# Verify execute was called with the correct commands
session.execute.assert_any_call(
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString'
)
session.execute.assert_any_call(
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
)
# Both Exchange and Teams connections should be tested
session.execute.assert_any_call(
"Connect-ExchangeOnline -Credential $credential"
)
session.execute.assert_any_call(
"Connect-MicrosoftTeams -Credential $credential"
)
session.close()
@patch("subprocess.Popen")
def test_test_credentials_application_auth(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
with patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365Credentials",
autospec=True,
) as mock_creds:
credentials = mock_creds.return_value
credentials.user = ""
credentials.passwd = ""
credentials.encrypted_passwd = ""
credentials.client_id = "test_client_id"
credentials.client_secret = "test_client_secret"
credentials.tenant_id = "test_tenant_id"
identity = M365IdentityInfo(
identity_id="test_id",
identity_type="Application",
tenant_id="test_tenant",
tenant_domain="contoso.onmicrosoft.com",
tenant_domains=["contoso.onmicrosoft.com"],
location="test_location",
)
session = M365PowerShell(credentials, identity)
session.execute = MagicMock(return_value="sometoken")
credentials = M365Credentials(
user="",
passwd="",
encrypted_passwd="",
client_id="test_client_id",
client_secret="test_client_secret",
tenant_id="test_tenant_id",
)
identity = M365IdentityInfo(
identity_id="test_id",
identity_type="Application",
tenant_id="test_tenant",
tenant_domain="contoso.onmicrosoft.com",
tenant_domains=["contoso.onmicrosoft.com"],
location="test_location",
)
session = M365PowerShell(credentials, identity)
session.execute = MagicMock(return_value="sometoken")
result = session.test_credentials(credentials)
assert result is True
session.execute.assert_any_call("Write-Output $graphToken")
session.close()
result = session.test_credentials(credentials)
assert result is True
session.execute.assert_any_call("Write-Output $graphToken")
session.close()
@patch("subprocess.Popen")
@patch("msal.ConfidentialClientApplication")
@@ -748,7 +822,8 @@ class Testm365PowerShell:
assert result is True
# Verify all expected PowerShell commands were called
assert session.execute.call_count == 3
# 4 calls: teamstokenBody, teamsToken, Write-Output $teamsToken, Connect-MicrosoftTeams
assert session.execute.call_count == 4
mock_decode_jwt.assert_called_once_with("valid_teams_token")
session.close()
@@ -849,7 +924,8 @@ class Testm365PowerShell:
assert result is True
# Verify all expected PowerShell commands were called
assert session.execute.call_count == 3
# 4 calls: SecureSecret, exchangeToken, Write-Output $exchangeToken, Connect-ExchangeOnline
assert session.execute.call_count == 4
mock_decode_msal_token.assert_called_once_with("valid_exchange_token")
session.close()
@@ -970,3 +1046,623 @@ class Testm365PowerShell:
del sys.modules["win32crypt"]
session.close()
@patch("subprocess.Popen")
def test_clean_certificate_content(self, mock_popen):
"""Test clean_certificate_content method with various certificate content formats"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Test with clean base64 content
clean_cert = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV"
result = session.clean_certificate_content(clean_cert)
assert result == clean_cert
# Test with newlines
cert_with_newlines = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
expected = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
result = session.clean_certificate_content(cert_with_newlines)
assert result == expected
# Test with carriage returns
cert_with_cr = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV\rBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
result = session.clean_certificate_content(cert_with_cr)
assert result == expected
# Test with spaces
cert_with_spaces = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
result = session.clean_certificate_content(cert_with_spaces)
assert result == expected
# Test with combination of all whitespace types
cert_mixed = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV\n\r BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
result = session.clean_certificate_content(cert_mixed)
assert result == expected
# Test with leading/trailing whitespace
cert_with_whitespace = " MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA== "
result = session.clean_certificate_content(cert_with_whitespace)
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_init_credential_certificate_auth(self, mock_popen):
"""Test init_credential method with certificate authentication"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
credentials = M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["example.com"],
)
identity = M365IdentityInfo(tenant_domains=["example.com"])
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock the execute method
execute_calls = []
def mock_execute(command):
execute_calls.append(command)
if (
"New-Object System.Security.Cryptography.X509Certificates.X509Certificate2"
in command
):
return "" # No error
return ""
session.execute = MagicMock(side_effect=mock_execute)
session.sanitize = MagicMock(side_effect=lambda x: x)
session.clean_certificate_content = MagicMock(return_value=certificate_content)
# Now call init_credential
session.init_credential(credentials)
# Verify clean_certificate_content was called
session.clean_certificate_content.assert_called_once_with(certificate_content)
# Verify sanitize was called for client_id and tenant_id
session.sanitize.assert_any_call(credentials.client_id)
session.sanitize.assert_any_call(credentials.tenant_id)
# Verify execute was called with correct commands
session.execute.assert_any_call(
f'$certBytes = [Convert]::FromBase64String("{certificate_content}")'
)
session.execute.assert_any_call(
"$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certBytes)"
)
session.execute.assert_any_call(f'$clientID = "{credentials.client_id}"')
session.execute.assert_any_call(f'$tenantID = "{credentials.tenant_id}"')
session.execute.assert_any_call(
f'$tenantDomain = "{credentials.tenant_domains[0]}"'
)
session.close()
@patch("subprocess.Popen")
def test_init_credential_certificate_auth_creation_error(self, mock_popen):
"""Test init_credential method with certificate authentication when certificate creation fails"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
certificate_content = base64.b64encode(b"invalid_certificate").decode("utf-8")
credentials = M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["example.com"],
)
identity = M365IdentityInfo(tenant_domains=["example.com"])
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock the execute method to simulate certificate creation error
def mock_execute(command):
if (
"New-Object System.Security.Cryptography.X509Certificates.X509Certificate2"
in command
):
return "Certificate creation failed: Invalid certificate format"
return ""
session.execute = MagicMock(side_effect=mock_execute)
session.sanitize = MagicMock(side_effect=lambda x: x)
session.clean_certificate_content = MagicMock(return_value=certificate_content)
with pytest.raises(M365CertificateCreationError) as exc_info:
session.init_credential(credentials)
# The actual error message format from the exception
assert "Failed to create X.509 certificate object" in str(exc_info.value)
session.close()
@patch("subprocess.Popen")
def test_test_exchange_certificate_connection_success(self, mock_popen):
"""Test test_exchange_certificate_connection method with successful connection"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock successful Exchange connection
session.execute = MagicMock(
return_value="Connected successfully https://aka.ms/exov3-module"
)
result = session.test_exchange_certificate_connection()
assert result is True
session.execute.assert_called_once_with(
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain"
)
session.close()
@patch("subprocess.Popen")
def test_test_exchange_certificate_connection_failure(self, mock_popen):
"""Test test_exchange_certificate_connection method with failed connection"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock failed Exchange connection
session.execute = MagicMock(
return_value="Connection failed: Authentication error"
)
result = session.test_exchange_certificate_connection()
assert result is False
session.execute.assert_called_once_with(
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain"
)
session.close()
@patch("subprocess.Popen")
def test_test_teams_certificate_connection_success(self, mock_popen):
"""Test test_teams_certificate_connection method with successful connection"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo(identity_id="test_identity_id")
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock successful Teams connection - the method returns bool
def mock_execute_side_effect(command):
if "Connect-MicrosoftTeams" in command:
# Return result that contains the identity_id for success
return "Connected successfully test_identity_id"
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
result = session.test_teams_certificate_connection()
assert result is True
session.execute.assert_called_once_with(
"Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID"
)
session.close()
@patch("subprocess.Popen")
def test_test_teams_certificate_connection_failure(self, mock_popen):
"""Test test_teams_certificate_connection method with failed connection"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock failed Teams connection
def mock_execute_side_effect(command, json_parse=False):
if "Connect-MicrosoftTeams" in command:
raise Exception("Connection failed: Authentication error")
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
# Should raise exception on connection failure
with pytest.raises(Exception) as exc_info:
session.test_teams_certificate_connection()
assert "Connection failed: Authentication error" in str(exc_info.value)
session.close()
@patch("subprocess.Popen")
def test_test_credentials_certificate_auth_success(self, mock_popen):
"""Test test_credentials method with certificate authentication - successful"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
credentials = M365Credentials(
client_id="test_client_id", certificate_content=certificate_content
)
identity = M365IdentityInfo()
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock successful certificate connections
# Note: The actual implementation uses "or" so if teams succeeds, exchange won't be called
session.test_teams_certificate_connection = MagicMock(return_value=True)
session.test_exchange_certificate_connection = MagicMock(return_value=True)
result = session.test_credentials(credentials)
assert result is True
session.test_teams_certificate_connection.assert_called_once()
# Exchange connection should NOT be called if teams connection succeeds (due to "or" logic)
session.test_exchange_certificate_connection.assert_not_called()
session.close()
@patch("subprocess.Popen")
def test_test_credentials_certificate_auth_failure(self, mock_popen):
"""Test test_credentials method with certificate authentication - failure"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
credentials = M365Credentials(
client_id="test_client_id", certificate_content=certificate_content
)
identity = M365IdentityInfo()
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock failed certificate connections - teams fails, so exchange is tried
session.test_teams_certificate_connection = MagicMock(return_value=False)
session.test_exchange_certificate_connection = MagicMock(return_value=False)
result = session.test_credentials(credentials)
assert result is True # Method always returns True after the try block
session.close()
@patch("subprocess.Popen")
def test_connect_microsoft_teams_certificate_auth(self, mock_popen):
"""Test connect_microsoft_teams method with certificate authentication"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock certificate variable check and teams connection
execute_calls = []
def mock_execute_side_effect(command, json_parse=False):
execute_calls.append(command)
if "Write-Output $certificate" in command:
return "certificate_content" # Non-empty means certificate exists
elif "Connect-MicrosoftTeams" in command:
return {"TenantId": "test-tenant-id", "Account": "test-account"}
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
session.test_teams_certificate_connection = MagicMock(
return_value={"success": True}
)
result = session.connect_microsoft_teams()
assert result == {"success": True}
session.test_teams_certificate_connection.assert_called_once()
session.close()
@patch("subprocess.Popen")
def test_connect_exchange_online_certificate_auth(self, mock_popen):
"""Test connect_exchange_online method with certificate authentication"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock certificate variable check and exchange connection
execute_calls = []
def mock_execute_side_effect(command, json_parse=False):
execute_calls.append(command)
if "Write-Output $certificate" in command:
return "certificate_content" # Non-empty means certificate exists
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
session.test_exchange_certificate_connection = MagicMock(return_value=True)
result = session.connect_exchange_online()
assert result is True
session.test_exchange_certificate_connection.assert_called_once()
session.close()
@patch("subprocess.Popen")
def test_clean_certificate_content_basic(self, mock_popen):
"""Test clean_certificate_content method with basic certificate content"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal",
),
)
# Test basic certificate content cleaning
cert_content = "LS0tLS1CRUdJTi\nBFUlRJRklD\rQVRFLS0tLS0\n"
expected = "LS0tLS1CRUdJTiBFUlRJRklDQVRFLS0tLS0"
result = session.clean_certificate_content(cert_content)
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_clean_certificate_content_with_spaces(self, mock_popen):
"""Test clean_certificate_content method with spaces in certificate content"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal",
),
)
# Test certificate content with spaces
cert_content = " LS0tLS1CRUdJTi BFUlRJRklD QVRFLS0tLS0 "
expected = "LS0tLS1CRUdJTiBFUlRJRklDQVRFLS0tLS0"
result = session.clean_certificate_content(cert_content)
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_clean_certificate_content_empty(self, mock_popen):
"""Test clean_certificate_content method with empty certificate content"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal",
),
)
# Test empty certificate content
cert_content = ""
expected = ""
result = session.clean_certificate_content(cert_content)
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_init_credential_certificate_auth_with_domains(self, mock_popen):
"""Test init_credential method with certificate authentication and tenant domains"""
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
executed_commands = []
def mock_execute(command):
executed_commands.append(command)
if "Error creating certificate" in command:
return None # No error
return ""
# Create session with non-certificate credentials first
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com", "subdomain.contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com", "subdomain.contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal with Certificate",
),
)
# Mock methods before calling init_credential
session.execute = MagicMock(side_effect=mock_execute)
session.sanitize = MagicMock(side_effect=lambda x: x)
session.clean_certificate_content = MagicMock(return_value=certificate_content)
# Now test certificate authentication
session.init_credential(
M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["contoso.com", "subdomain.contoso.com"],
)
)
# Verify that certificate content was cleaned
session.clean_certificate_content.assert_called_once_with(certificate_content)
# Verify certificate creation commands were executed
assert any(
"$certBytes = [Convert]::FromBase64String" in cmd
for cmd in executed_commands
)
assert any(
"$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2"
in cmd
for cmd in executed_commands
)
assert any('$clientID = "test_client_id"' in cmd for cmd in executed_commands)
assert any('$tenantID = "test_tenant_id"' in cmd for cmd in executed_commands)
assert any('$tenantDomain = "contoso.com"' in cmd for cmd in executed_commands)
session.close()
@patch("subprocess.Popen")
def test_test_credentials_certificate_auth_with_or_logic(self, mock_popen):
"""Test test_credentials method with certificate auth using OR logic between Teams and Exchange"""
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
# Create session with non-certificate credentials first
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal with Certificate",
),
)
# Mock that Teams connection fails but Exchange succeeds
session.test_teams_certificate_connection = MagicMock(return_value=False)
session.test_exchange_certificate_connection = MagicMock(return_value=True)
result = session.test_credentials(
M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["contoso.com"],
)
)
assert result is True
session.test_teams_certificate_connection.assert_called_once()
session.test_exchange_certificate_connection.assert_called_once()
session.close()
@patch("subprocess.Popen")
def test_test_credentials_certificate_auth_both_fail(self, mock_popen):
"""Test test_credentials method with certificate auth when both Teams and Exchange fail"""
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
# Create session with non-certificate credentials first
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal with Certificate",
),
)
# Mock that both connections fail
session.test_teams_certificate_connection = MagicMock(return_value=False)
session.test_exchange_certificate_connection = MagicMock(return_value=False)
# Even when both fail, the method should return True (this is the intended logic)
result = session.test_credentials(
M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["contoso.com"],
)
)
assert result is True
session.test_teams_certificate_connection.assert_called_once()
session.test_exchange_certificate_connection.assert_called_once()
session.close()
+2 -1
View File
@@ -1,5 +1,6 @@
from unittest.mock import MagicMock
from azure.identity import DefaultAzureCredential
from mock import MagicMock
from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.m365.models import (

Some files were not shown because too many files have changed in this diff Show More