mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
Merge branch 'master' into review-metadata-aws-neptune
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
#### **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 |
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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",
|
||||
|
||||
+32
@@ -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": ""
|
||||
}
|
||||
+21
@@ -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"},
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+5
@@ -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:
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+5
@@ -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}."
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+6
-5
@@ -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
|
||||
|
||||
+30
@@ -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
|
||||
+30
@@ -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": ""
|
||||
}
|
||||
+51
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-2
@@ -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"
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+1
@@ -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}."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+122
@@ -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
|
||||
+76
-6
@@ -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
|
||||
)
|
||||
|
||||
+71
-9
@@ -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
|
||||
|
||||
+4
-4
@@ -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",
|
||||
|
||||
+138
@@ -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"}
|
||||
|
||||
+333
-8
@@ -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][
|
||||
|
||||
+290
@@ -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"
|
||||
+323
@@ -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
|
||||
+2
-2
@@ -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
|
||||
|
||||
+54
@@ -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"
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user