mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-15 00:33:27 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f90b4d22b9 | |||
| e6d1b5639b | |||
| b1856e42f0 | |||
| ba8dbb0d28 | |||
| b436cc1cac | |||
| 51baa88644 | |||
| 5098b12e97 | |||
| 3d1e7015a6 | |||
| 0b7f02f7e4 | |||
| c0396e97bf | |||
| 8d4fa46038 | |||
| 4b160257b9 |
@@ -8,7 +8,7 @@ body:
|
||||
attributes:
|
||||
label: Feature search
|
||||
options:
|
||||
- label: I have searched the existing issues and this feature has not been requested yet
|
||||
- label: I have searched the existing issues and this feature has not been requested yet or is already in our [Public Roadmap](https://roadmap.prowler.com/roadmap)
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: component
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.14.0] (Prowler UNRELEASED)
|
||||
## [1.14.0] (Prowler 5.13.0)
|
||||
|
||||
### Added
|
||||
- Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655)
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Set
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from rich.align import Align
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from rich.panel import Panel
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
)
|
||||
from rich.prompt import Confirm
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.theme import Theme
|
||||
|
||||
from ...db_router import MainRouter
|
||||
from ...models import Scan, StateChoices
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Check for stuck scans and mark them as failed"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.console = Console(theme=self.get_custom_theme())
|
||||
self.logger = None
|
||||
|
||||
def get_custom_theme(self):
|
||||
"""Create a custom theme without purple colors"""
|
||||
return Theme(
|
||||
{
|
||||
"prompt.choices": "bright_cyan",
|
||||
"prompt.default": "bright_white",
|
||||
"progress.description": "bright_white",
|
||||
"progress.percentage": "bright_cyan",
|
||||
"progress.data.speed": "bright_green",
|
||||
"progress.spinner": "bright_cyan",
|
||||
}
|
||||
)
|
||||
|
||||
def setup_logging(self, verbose=False):
|
||||
"""Setup rich logging handler"""
|
||||
if verbose:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
datefmt="[%X]",
|
||||
handlers=[RichHandler(console=self.console, rich_tracebacks=True)],
|
||||
)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
else:
|
||||
# Create a no-op logger
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger.addHandler(logging.NullHandler())
|
||||
self.logger.setLevel(logging.CRITICAL)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Mark stuck scans as failed without confirmation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be done without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", action="store_true", help="Enable verbose logging"
|
||||
)
|
||||
|
||||
def get_celery_app(self):
|
||||
"""Get the Celery application instance"""
|
||||
try:
|
||||
from config.celery import celery_app
|
||||
|
||||
return celery_app
|
||||
except ImportError:
|
||||
raise CommandError("Could not import Celery app from config.celery")
|
||||
|
||||
def get_active_task_ids(self) -> Set[str]:
|
||||
"""Get all active task IDs from all Celery workers"""
|
||||
celery_app = self.get_celery_app()
|
||||
inspect = celery_app.control.inspect()
|
||||
|
||||
active_task_ids = set()
|
||||
|
||||
try:
|
||||
# Get active tasks from all workers
|
||||
active_tasks = inspect.active()
|
||||
if active_tasks:
|
||||
for worker, tasks in active_tasks.items():
|
||||
for task in tasks:
|
||||
active_task_ids.add(task["id"])
|
||||
|
||||
# Get scheduled tasks from all workers
|
||||
scheduled_tasks = inspect.scheduled()
|
||||
if scheduled_tasks:
|
||||
for worker, tasks in scheduled_tasks.items():
|
||||
for task in tasks:
|
||||
active_task_ids.add(task["id"])
|
||||
|
||||
# Get reserved tasks from all workers
|
||||
reserved_tasks = inspect.reserved()
|
||||
if reserved_tasks:
|
||||
for worker, tasks in reserved_tasks.items():
|
||||
for task in tasks:
|
||||
active_task_ids.add(task["id"])
|
||||
|
||||
except Exception as e:
|
||||
if self.logger and hasattr(self.logger, "error"):
|
||||
self.logger.error(f"Error connecting to Celery broker: {e}")
|
||||
raise CommandError(f"Failed to connect to Celery broker: {e}")
|
||||
|
||||
return active_task_ids
|
||||
|
||||
def find_stuck_scans(self) -> List[Dict]:
|
||||
"""Find scans that appear to be stuck with interactive progress"""
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
console=self.console,
|
||||
transient=True,
|
||||
) as progress:
|
||||
|
||||
# Step 1: Find executing scans
|
||||
scan_task = progress.add_task(
|
||||
"🔍 Scanning for executing scans...", total=100
|
||||
)
|
||||
|
||||
executing_scans = (
|
||||
Scan.objects.using(MainRouter.admin_db)
|
||||
.filter(state=StateChoices.EXECUTING)
|
||||
.select_related("task__task_runner_task", "provider")
|
||||
.exclude(task__isnull=True)
|
||||
.exclude(task__task_runner_task__isnull=True)
|
||||
)
|
||||
|
||||
progress.update(scan_task, advance=50)
|
||||
time.sleep(0.5) # Small delay for visual effect
|
||||
|
||||
scan_count = executing_scans.count()
|
||||
progress.update(
|
||||
scan_task,
|
||||
advance=50,
|
||||
description=f"✅ Found {scan_count} executing scans",
|
||||
)
|
||||
time.sleep(0.3)
|
||||
|
||||
if scan_count == 0:
|
||||
return []
|
||||
|
||||
# Step 2: Get active tasks from Celery
|
||||
celery_task = progress.add_task("🔄 Checking Celery workers...", total=100)
|
||||
active_task_ids = self.get_active_task_ids()
|
||||
progress.update(
|
||||
celery_task,
|
||||
advance=100,
|
||||
description=f"✅ Found {len(active_task_ids)} active tasks",
|
||||
)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Step 3: Check each scan
|
||||
check_task = progress.add_task("🕵️ Analyzing scans...", total=scan_count)
|
||||
stuck_scans = []
|
||||
|
||||
for i, scan in enumerate(executing_scans):
|
||||
progress.update(
|
||||
check_task,
|
||||
advance=1,
|
||||
description=f"🕵️ Analyzing scan {i + 1}/{scan_count}",
|
||||
)
|
||||
|
||||
task_result = scan.task.task_runner_task
|
||||
task_id = task_result.task_id
|
||||
|
||||
# Check if task is still active in any worker
|
||||
if task_id not in active_task_ids:
|
||||
stuck_scans.append(
|
||||
{
|
||||
"scan": scan,
|
||||
"task_result": task_result,
|
||||
}
|
||||
)
|
||||
|
||||
time.sleep(0.3) # Small delay for visual effect
|
||||
|
||||
progress.update(
|
||||
check_task,
|
||||
description=f"✅ Analysis complete - {len(stuck_scans)} stuck scans found",
|
||||
)
|
||||
time.sleep(2)
|
||||
|
||||
return stuck_scans
|
||||
|
||||
def display_scan_details(self, scan, task_result):
|
||||
"""Display detailed information about a single scan"""
|
||||
|
||||
# Create scan details panel
|
||||
scan_info = Text()
|
||||
scan_info.append("🆔 Scan ID: ", style="bold cyan")
|
||||
scan_info.append(f"{scan.id}\n", style="cyan")
|
||||
|
||||
scan_info.append("🏢 Tenant ID: ", style="bold bright_blue")
|
||||
scan_info.append(f"{scan.tenant_id}\n", style="bright_blue")
|
||||
|
||||
scan_info.append("☁️ Provider: ", style="bold green")
|
||||
scan_info.append(f"{scan.provider.provider.upper()}\n", style="green")
|
||||
|
||||
scan_info.append("🔗 Provider UID: ", style="bold green")
|
||||
scan_info.append(f"{scan.provider.uid}\n", style="green")
|
||||
|
||||
scan_info.append("⏰ Started At: ", style="bold yellow")
|
||||
started_time = (
|
||||
scan.started_at.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
if scan.started_at
|
||||
else "Unknown"
|
||||
)
|
||||
scan_info.append(f"{started_time}\n", style="yellow")
|
||||
|
||||
scan_info.append("📝 Scan Name: ", style="bold white")
|
||||
scan_info.append(f"{scan.name or 'No name'}\n", style="white")
|
||||
|
||||
scan_info.append("🔄 Task ID: ", style="bold blue")
|
||||
scan_info.append(f"{task_result.task_id}\n", style="blue")
|
||||
|
||||
scan_info.append("📊 Task Status: ", style="bold red")
|
||||
scan_info.append(f"{task_result.status or 'Unknown'}\n", style="red")
|
||||
|
||||
if scan.started_at:
|
||||
duration = datetime.now(timezone.utc) - scan.started_at
|
||||
scan_info.append("⏱️ Running For: ", style="bold bright_cyan")
|
||||
scan_info.append(f"{duration}\n", style="bright_cyan")
|
||||
|
||||
return Panel(
|
||||
scan_info,
|
||||
title="🚨 Stuck Scan Detected",
|
||||
border_style="red",
|
||||
title_align="center",
|
||||
)
|
||||
|
||||
def display_stuck_scans(self, stuck_scans: List[Dict], force: bool = False):
|
||||
"""Display stuck scans interactively"""
|
||||
if not stuck_scans:
|
||||
self.console.print("\n")
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center(
|
||||
"🎉 No stuck scans found!\nAll scans are running properly."
|
||||
),
|
||||
style="green",
|
||||
title="✅ All Clear",
|
||||
)
|
||||
)
|
||||
return []
|
||||
|
||||
# Show summary first
|
||||
self.console.print("\n")
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center(
|
||||
f"⚠️ Found {len(stuck_scans)} stuck scan{'s' if len(stuck_scans) != 1 else ''}"
|
||||
),
|
||||
style="yellow",
|
||||
title="🔍 Detection Results",
|
||||
)
|
||||
)
|
||||
|
||||
if force:
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center(
|
||||
"🚀 Force mode enabled - marking all stuck scans as failed"
|
||||
),
|
||||
style="cyan",
|
||||
)
|
||||
)
|
||||
return stuck_scans
|
||||
|
||||
confirmed_scans = []
|
||||
|
||||
for i, stuck_scan in enumerate(stuck_scans, 1):
|
||||
self.console.clear()
|
||||
self.console.print(
|
||||
Panel.fit("🔍 Prowler Stuck Scans Checker", style="bold blue")
|
||||
)
|
||||
|
||||
scan = stuck_scan["scan"]
|
||||
task_result = stuck_scan["task_result"]
|
||||
|
||||
# Show progress
|
||||
progress_text = f"Reviewing scan {i} of {len(stuck_scans)}"
|
||||
self.console.print(f"\n{progress_text}", style="dim")
|
||||
|
||||
# Show scan details
|
||||
self.console.print("\n")
|
||||
self.console.print(self.display_scan_details(scan, task_result))
|
||||
|
||||
# Ask for confirmation
|
||||
self.console.print("\n")
|
||||
if Confirm.ask(
|
||||
"❓ Mark this scan as failed?", console=self.console, default=False
|
||||
):
|
||||
confirmed_scans.append(stuck_scan)
|
||||
self.console.print("✅ Scan will be marked as failed", style="green")
|
||||
else:
|
||||
self.console.print("⏭️ Scan skipped", style="yellow")
|
||||
|
||||
# Small pause before next scan (except for last one)
|
||||
if i < len(stuck_scans):
|
||||
time.sleep(0.5)
|
||||
|
||||
return confirmed_scans
|
||||
|
||||
def mark_scans_as_failed(self, stuck_scans: List[Dict], dry_run: bool = False):
|
||||
"""Mark stuck scans as failed with interactive progress"""
|
||||
if not stuck_scans:
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
self.console.print("\n")
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center(
|
||||
f"🧪 DRY RUN: Would mark {len(stuck_scans)} scan{'s' if len(stuck_scans) != 1 else ''} as failed"
|
||||
),
|
||||
style="yellow",
|
||||
title="🔍 Dry Run Results",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Show processing animation
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
console=self.console,
|
||||
transient=True,
|
||||
) as progress:
|
||||
|
||||
task = progress.add_task(
|
||||
"🔧 Marking scans as failed...", total=len(stuck_scans)
|
||||
)
|
||||
failed_count = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for i, stuck_scan in enumerate(stuck_scans):
|
||||
scan = stuck_scan["scan"]
|
||||
task_result = stuck_scan["task_result"]
|
||||
|
||||
progress.update(
|
||||
task,
|
||||
advance=1,
|
||||
description=f"🔧 Processing scan {i + 1}/{len(stuck_scans)}",
|
||||
)
|
||||
|
||||
try:
|
||||
# Update scan state to FAILED using admin connection
|
||||
scan.state = StateChoices.FAILED
|
||||
scan.completed_at = datetime.now(timezone.utc)
|
||||
scan.save(
|
||||
using=MainRouter.admin_db,
|
||||
update_fields=["state", "completed_at"],
|
||||
)
|
||||
|
||||
task_result.status = "FAILURE"
|
||||
task_result.result = json.dumps(
|
||||
{
|
||||
"exc_type": "ScanStuckError",
|
||||
"exc_message": [
|
||||
"Scan was detected as stuck and marked as failed."
|
||||
],
|
||||
}
|
||||
)
|
||||
task_result.date_done = datetime.now(timezone.utc)
|
||||
task_result.save(using=MainRouter.admin_db)
|
||||
|
||||
failed_count += 1
|
||||
if self.logger and hasattr(self.logger, "info"):
|
||||
self.logger.info(
|
||||
f"Marked scan {scan.id} (tenant: {scan.tenant_id}) as failed"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if self.logger and hasattr(self.logger, "error"):
|
||||
self.logger.error(f"Failed to update scan {scan.id}: {e}")
|
||||
|
||||
time.sleep(0.2) # Small delay for visual effect
|
||||
|
||||
progress.update(
|
||||
task,
|
||||
description=f"✅ Completed - {failed_count} scans marked as failed",
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
# Show final results
|
||||
self.console.print("\n")
|
||||
if failed_count > 0:
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center(
|
||||
f"🎉 Successfully marked {failed_count} scan{'s' if failed_count != 1 else ''} as failed"
|
||||
),
|
||||
style="green",
|
||||
title="✅ Task Complete",
|
||||
)
|
||||
)
|
||||
|
||||
# Show summary table
|
||||
self.show_summary_table(stuck_scans, failed_count)
|
||||
else:
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center("⚠️ No scans were updated"),
|
||||
style="yellow",
|
||||
title="⚠️ Warning",
|
||||
)
|
||||
)
|
||||
|
||||
def show_summary_table(self, processed_scans: List[Dict], success_count: int):
|
||||
"""Show a summary table of processed scans"""
|
||||
if success_count == 0:
|
||||
return
|
||||
|
||||
self.console.print("\n")
|
||||
|
||||
# Create summary table
|
||||
table = Table(
|
||||
title=f"📋 Summary - {success_count} Scan{'s' if success_count != 1 else ''} Marked as Failed",
|
||||
show_header=True,
|
||||
header_style="bold white",
|
||||
title_style="bold green",
|
||||
border_style="green",
|
||||
)
|
||||
|
||||
table.add_column("🆔 Scan ID", style="cyan", no_wrap=True)
|
||||
table.add_column("🏢 Tenant", style="bright_blue", no_wrap=True)
|
||||
table.add_column("☁️ Provider", style="green", no_wrap=True)
|
||||
table.add_column("⏰ Started At", style="yellow")
|
||||
table.add_column("📝 Scan Name", style="blue")
|
||||
|
||||
for scan_data in processed_scans:
|
||||
scan = scan_data["scan"]
|
||||
|
||||
# Show full IDs since we have no_wrap=True
|
||||
scan_id_full = str(scan.id)
|
||||
tenant_id_full = str(scan.tenant_id) if scan.tenant_id else "Unknown"
|
||||
|
||||
# Format provider info with full details
|
||||
provider_info = f"{scan.provider.provider.upper()}: {scan.provider.uid}"
|
||||
|
||||
# Format start time
|
||||
started_time = (
|
||||
scan.started_at.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
if scan.started_at
|
||||
else "Unknown"
|
||||
)
|
||||
|
||||
# Get scan name
|
||||
scan_name = scan.name or "N/A"
|
||||
|
||||
table.add_row(
|
||||
scan_id_full, tenant_id_full, provider_info, started_time, scan_name
|
||||
)
|
||||
|
||||
self.console.print(table)
|
||||
|
||||
# Add helpful note
|
||||
self.console.print("\n")
|
||||
self.console.print(
|
||||
Panel(
|
||||
"💡 These scans were stuck (executing but no active task in workers) and have been marked as failed.\n"
|
||||
"You can now retry them from the Prowler interface.",
|
||||
style="dim",
|
||||
title="ℹ️ Note",
|
||||
border_style="dim",
|
||||
)
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
force = options["force"]
|
||||
dry_run = options["dry_run"]
|
||||
verbose = options["verbose"]
|
||||
|
||||
# Setup logging based on verbose flag
|
||||
self.setup_logging(verbose)
|
||||
|
||||
# Clear screen and show header
|
||||
self.console.clear()
|
||||
self.console.print(
|
||||
Panel.fit("🔍 Prowler Stuck Scans Checker", style="bold blue")
|
||||
)
|
||||
|
||||
if self.logger and hasattr(self.logger, "info"):
|
||||
self.logger.info("Starting stuck scans check across all tenants...")
|
||||
|
||||
try:
|
||||
# Find stuck scans with interactive progress
|
||||
stuck_scans = self.find_stuck_scans()
|
||||
|
||||
# Display results interactively
|
||||
scans_to_process = self.display_stuck_scans(stuck_scans, force)
|
||||
|
||||
if not scans_to_process:
|
||||
if stuck_scans and not force:
|
||||
# User didn't confirm any scans
|
||||
self.console.print("\n")
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center("🚫 No scans selected for processing"),
|
||||
style="yellow",
|
||||
title="❌ Operation Cancelled",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Mark confirmed scans as failed
|
||||
self.mark_scans_as_failed(scans_to_process, dry_run)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.console.print("\n")
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center("🛑 Operation cancelled by user"),
|
||||
style="red",
|
||||
title="❌ Interrupted",
|
||||
)
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
if self.logger and hasattr(self.logger, "error"):
|
||||
self.logger.error(f"Error during stuck scans check: {e}")
|
||||
self.console.print("\n")
|
||||
self.console.print(
|
||||
Panel(
|
||||
Align.center(f"💥 Error: {str(e)}"),
|
||||
style="red",
|
||||
title="❌ Command Failed",
|
||||
)
|
||||
)
|
||||
raise CommandError(f"Command failed: {e}")
|
||||
|
||||
if self.logger and hasattr(self.logger, "info"):
|
||||
self.logger.info("Stuck scans check completed successfully")
|
||||
@@ -24,7 +24,7 @@ class Migration(migrations.Migration):
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
max_length=255,
|
||||
max_length=100,
|
||||
validators=[django.core.validators.MinLengthValidator(3)],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2689,6 +2689,55 @@ class TestScanViewSet:
|
||||
== "There is a problem with credentials."
|
||||
)
|
||||
|
||||
@patch("api.v1.views.ScanViewSet._get_task_status")
|
||||
@patch("api.v1.views.get_s3_client")
|
||||
@patch("api.v1.views.env.str")
|
||||
def test_threatscore_s3_wildcard(
|
||||
self,
|
||||
mock_env_str,
|
||||
mock_get_s3_client,
|
||||
mock_get_task_status,
|
||||
authenticated_client,
|
||||
scans_fixture,
|
||||
):
|
||||
"""
|
||||
When the threatscore endpoint is called with an S3 output_location,
|
||||
the view should list objects in S3 using wildcard pattern matching,
|
||||
retrieve the matching PDF file, and return it with HTTP 200 and proper headers.
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
bucket = "test-bucket"
|
||||
zip_key = "tenant-id/scan-id/prowler-output-foo.zip"
|
||||
scan.output_location = f"s3://{bucket}/{zip_key}"
|
||||
scan.save()
|
||||
|
||||
pdf_key = os.path.join(
|
||||
os.path.dirname(zip_key),
|
||||
"threatscore",
|
||||
"prowler-output-123_threatscore_report.pdf",
|
||||
)
|
||||
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.list_objects_v2.return_value = {"Contents": [{"Key": pdf_key}]}
|
||||
mock_s3_client.get_object.return_value = {"Body": io.BytesIO(b"pdf-bytes")}
|
||||
|
||||
mock_env_str.return_value = bucket
|
||||
mock_get_s3_client.return_value = mock_s3_client
|
||||
mock_get_task_status.return_value = None
|
||||
|
||||
url = reverse("scan-threatscore", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response["Content-Type"] == "application/pdf"
|
||||
assert response["Content-Disposition"].endswith(
|
||||
'"prowler-output-123_threatscore_report.pdf"'
|
||||
)
|
||||
assert response.content == b"pdf-bytes"
|
||||
mock_s3_client.list_objects_v2.assert_called_once()
|
||||
mock_s3_client.get_object.assert_called_once_with(Bucket=bucket, Key=pdf_key)
|
||||
|
||||
def test_report_s3_success(self, authenticated_client, scans_fixture, monkeypatch):
|
||||
"""
|
||||
When output_location is an S3 URL and the S3 client returns the file successfully,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fnmatch
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
@@ -1775,7 +1776,18 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
contents = resp.get("Contents", [])
|
||||
keys = [obj["Key"] for obj in contents if obj["Key"].endswith(suffix)]
|
||||
keys = []
|
||||
for obj in contents:
|
||||
key = obj["Key"]
|
||||
key_basename = os.path.basename(key)
|
||||
if any(ch in suffix for ch in ("*", "?", "[")):
|
||||
if fnmatch.fnmatch(key_basename, suffix):
|
||||
keys.append(key)
|
||||
elif key_basename == suffix:
|
||||
keys.append(key)
|
||||
elif key.endswith(suffix):
|
||||
# Backward compatibility if suffix already includes directories
|
||||
keys.append(key)
|
||||
if not keys:
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -20,10 +20,10 @@ from prowler.lib.outputs.asff.asff import ASFF
|
||||
from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected import (
|
||||
AWSWellArchitected,
|
||||
)
|
||||
from prowler.lib.outputs.compliance.c5.c5_aws import AWSC5
|
||||
from prowler.lib.outputs.compliance.ccc.ccc_aws import CCC_AWS
|
||||
from prowler.lib.outputs.compliance.ccc.ccc_azure import CCC_Azure
|
||||
from prowler.lib.outputs.compliance.ccc.ccc_gcp import CCC_GCP
|
||||
from prowler.lib.outputs.compliance.c5.c5_aws import AWSC5
|
||||
from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS
|
||||
@@ -183,18 +183,21 @@ def get_s3_client():
|
||||
return s3_client
|
||||
|
||||
|
||||
def _upload_to_s3(tenant_id: str, zip_path: str, scan_id: str) -> str | None:
|
||||
def _upload_to_s3(
|
||||
tenant_id: str, scan_id: str, local_path: str, relative_key: str
|
||||
) -> str | None:
|
||||
"""
|
||||
Upload the specified ZIP file to an S3 bucket.
|
||||
If the S3 bucket environment variables are not configured,
|
||||
the function returns None without performing an upload.
|
||||
Upload a local artifact to an S3 bucket under the tenant/scan prefix.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant identifier, used as part of the S3 key prefix.
|
||||
zip_path (str): The local file system path to the ZIP file to be uploaded.
|
||||
scan_id (str): The scan identifier, used as part of the S3 key prefix.
|
||||
tenant_id (str): The tenant identifier used as the first segment of the S3 key.
|
||||
scan_id (str): The scan identifier used as the second segment of the S3 key.
|
||||
local_path (str): Filesystem path to the artifact to upload.
|
||||
relative_key (str): Object key relative to `<tenant_id>/<scan_id>/`.
|
||||
|
||||
Returns:
|
||||
str: The S3 URI of the uploaded file (e.g., "s3://<bucket>/<key>") if successful.
|
||||
None: If the required environment variables for the S3 bucket are not set.
|
||||
str | None: S3 URI of the uploaded artifact, or None if the upload is skipped.
|
||||
|
||||
Raises:
|
||||
botocore.exceptions.ClientError: If the upload attempt to S3 fails for any reason.
|
||||
"""
|
||||
@@ -202,27 +205,19 @@ def _upload_to_s3(tenant_id: str, zip_path: str, scan_id: str) -> str | None:
|
||||
if not bucket:
|
||||
return
|
||||
|
||||
if not relative_key:
|
||||
return
|
||||
|
||||
if not os.path.isfile(local_path):
|
||||
return
|
||||
|
||||
try:
|
||||
s3 = get_s3_client()
|
||||
|
||||
# Upload the ZIP file (outputs) to the S3 bucket
|
||||
zip_key = f"{tenant_id}/{scan_id}/{os.path.basename(zip_path)}"
|
||||
s3.upload_file(
|
||||
Filename=zip_path,
|
||||
Bucket=bucket,
|
||||
Key=zip_key,
|
||||
)
|
||||
s3_key = f"{tenant_id}/{scan_id}/{relative_key}"
|
||||
s3.upload_file(Filename=local_path, Bucket=bucket, Key=s3_key)
|
||||
|
||||
# Upload the compliance directory to the S3 bucket
|
||||
compliance_dir = os.path.join(os.path.dirname(zip_path), "compliance")
|
||||
for filename in os.listdir(compliance_dir):
|
||||
local_path = os.path.join(compliance_dir, filename)
|
||||
if not os.path.isfile(local_path):
|
||||
continue
|
||||
file_key = f"{tenant_id}/{scan_id}/compliance/{filename}"
|
||||
s3.upload_file(Filename=local_path, Bucket=bucket, Key=file_key)
|
||||
|
||||
return f"s3://{base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET}/{zip_key}"
|
||||
return f"s3://{base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET}/{s3_key}"
|
||||
except (ClientError, NoCredentialsError, ParamValidationError, ValueError) as e:
|
||||
logger.error(f"S3 upload failed: {str(e)}")
|
||||
|
||||
|
||||
@@ -1317,7 +1317,12 @@ def generate_threatscore_report_job(
|
||||
min_risk_level=4,
|
||||
)
|
||||
|
||||
upload_uri = _upload_to_s3(tenant_id, pdf_path, scan_id)
|
||||
upload_uri = _upload_to_s3(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
pdf_path,
|
||||
f"threatscore/{Path(pdf_path).name}",
|
||||
)
|
||||
if upload_uri:
|
||||
try:
|
||||
rmtree(Path(pdf_path).parent, ignore_errors=True)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
@@ -413,7 +414,24 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
writer._data.clear()
|
||||
|
||||
compressed = _compress_output_files(out_dir)
|
||||
upload_uri = _upload_to_s3(tenant_id, compressed, scan_id)
|
||||
|
||||
upload_uri = _upload_to_s3(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compressed,
|
||||
os.path.basename(compressed),
|
||||
)
|
||||
|
||||
compliance_dir_path = Path(comp_dir).parent
|
||||
if compliance_dir_path.exists():
|
||||
for artifact_path in sorted(compliance_dir_path.iterdir()):
|
||||
if artifact_path.is_file():
|
||||
_upload_to_s3(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
str(artifact_path),
|
||||
f"compliance/{artifact_path.name}",
|
||||
)
|
||||
|
||||
# S3 integrations (need output_directory)
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
|
||||
@@ -72,17 +72,26 @@ class TestOutputs:
|
||||
client_mock = MagicMock()
|
||||
mock_get_client.return_value = client_mock
|
||||
|
||||
result = _upload_to_s3("tenant-id", str(zip_path), "scan-id")
|
||||
result = _upload_to_s3(
|
||||
"tenant-id",
|
||||
"scan-id",
|
||||
str(zip_path),
|
||||
"outputs.zip",
|
||||
)
|
||||
|
||||
expected_uri = "s3://test-bucket/tenant-id/scan-id/outputs.zip"
|
||||
assert result == expected_uri
|
||||
assert client_mock.upload_file.call_count == 2
|
||||
client_mock.upload_file.assert_called_once_with(
|
||||
Filename=str(zip_path),
|
||||
Bucket="test-bucket",
|
||||
Key="tenant-id/scan-id/outputs.zip",
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.export.get_s3_client")
|
||||
@patch("tasks.jobs.export.base")
|
||||
def test_upload_to_s3_missing_bucket(self, mock_base, mock_get_client):
|
||||
mock_base.DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = ""
|
||||
result = _upload_to_s3("tenant", "/tmp/fake.zip", "scan")
|
||||
result = _upload_to_s3("tenant", "scan", "/tmp/fake.zip", "fake.zip")
|
||||
assert result is None
|
||||
|
||||
@patch("tasks.jobs.export.get_s3_client")
|
||||
@@ -101,11 +110,15 @@ class TestOutputs:
|
||||
client_mock = MagicMock()
|
||||
mock_get_client.return_value = client_mock
|
||||
|
||||
result = _upload_to_s3("tenant", str(zip_path), "scan")
|
||||
result = _upload_to_s3(
|
||||
"tenant",
|
||||
"scan",
|
||||
str(compliance_dir / "subdir"),
|
||||
"compliance/subdir",
|
||||
)
|
||||
|
||||
expected_uri = "s3://test-bucket/tenant/scan/results.zip"
|
||||
assert result == expected_uri
|
||||
client_mock.upload_file.assert_called_once()
|
||||
assert result is None
|
||||
client_mock.upload_file.assert_not_called()
|
||||
|
||||
@patch(
|
||||
"tasks.jobs.export.get_s3_client",
|
||||
@@ -126,7 +139,12 @@ class TestOutputs:
|
||||
compliance_dir.mkdir()
|
||||
(compliance_dir / "report.csv").write_text("csv")
|
||||
|
||||
_upload_to_s3("tenant", str(zip_path), "scan")
|
||||
_upload_to_s3(
|
||||
"tenant",
|
||||
"scan",
|
||||
str(zip_path),
|
||||
"zipfile.zip",
|
||||
)
|
||||
mock_logger.assert_called()
|
||||
|
||||
@patch("tasks.jobs.export.rls_transaction")
|
||||
|
||||
@@ -85,6 +85,12 @@ class TestGenerateThreatscoreReport:
|
||||
only_failed=True,
|
||||
min_risk_level=4,
|
||||
)
|
||||
mock_upload.assert_called_once_with(
|
||||
self.tenant_id,
|
||||
self.scan_id,
|
||||
"/tmp/threatscore_path_threatscore_report.pdf",
|
||||
"threatscore/threatscore_path_threatscore_report.pdf",
|
||||
)
|
||||
mock_rmtree.assert_called_once_with(
|
||||
Path("/tmp/threatscore_path_threatscore_report.pdf").parent,
|
||||
ignore_errors=True,
|
||||
|
||||
@@ -118,6 +118,33 @@ services:
|
||||
- "../docker-entrypoint.sh"
|
||||
- "beat"
|
||||
|
||||
check-scans:
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=config.django.devel
|
||||
- DJANGO_LOGGING_FORMATTER=${LOGGING_FORMATTER:-human_readable}
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- "./api/src/backend:/home/prowler/backend"
|
||||
- "./api/pyproject.toml:/home/prowler/pyproject.toml"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
stdin_open: true
|
||||
tty: true
|
||||
working_dir: /home/prowler/backend
|
||||
entrypoint: []
|
||||
command: ["poetry", "run", "python", "manage.py", "check_scans"]
|
||||
profiles:
|
||||
- tools
|
||||
|
||||
volumes:
|
||||
outputs:
|
||||
driver: local
|
||||
|
||||
+3377
-95
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,7 @@ title: 'Prowler Services'
|
||||
Here you can find how to create a new service, or to complement an existing one, for a [Prowler Provider](/developer-guide/provider).
|
||||
|
||||
<Note>
|
||||
First ensure that the provider you want to add the service is already created. It can be checked [here](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). If the provider is not present, please refer to the [Provider](/developer-guide/provider) documentation to create it from scratch.
|
||||
|
||||
First ensure that the provider you want to add the service is already created. It can be checked [here](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). If the provider is not present, please refer to the [Provider](./provider.md) documentation to create it from scratch.
|
||||
</Note>
|
||||
## Introduction
|
||||
|
||||
@@ -201,11 +200,11 @@ class <Item>(BaseModel):
|
||||
|
||||
#### Service Attributes
|
||||
|
||||
*Optimized Data Storage with Python Dictionaries*
|
||||
_Optimized Data Storage with Python Dictionaries_
|
||||
|
||||
Each group of resources within a service should be structured as a Python [dictionary](https://docs.python.org/3/tutorial/datastructures.html#dictionaries) to enable efficient lookups. The dictionary lookup operation has [O(1) complexity](https://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions), and lookups are constantly executed.
|
||||
|
||||
*Assigning Unique Identifiers*
|
||||
_Assigning Unique Identifiers_
|
||||
|
||||
Each dictionary key must be a unique ID to identify the resource in a univocal way.
|
||||
|
||||
@@ -241,6 +240,301 @@ Provider-Specific Permissions Documentation:
|
||||
- [M365](/user-guide/providers/microsoft365/authentication#required-permissions)
|
||||
- [GitHub](/user-guide/providers/github/authentication)
|
||||
|
||||
## Service Architecture and Cross-Service Communication
|
||||
|
||||
### Core Principle: Service Isolation with Client Communication
|
||||
|
||||
Each service must contain **ONLY** the information unique to that specific service. When a check requires information from multiple services, it must use the **client objects** of other services rather than directly accessing their data structures.
|
||||
|
||||
This architecture ensures:
|
||||
|
||||
- **Loose coupling** between services
|
||||
- **Clear separation of concerns**
|
||||
- **Maintainable and testable code**
|
||||
- **Consistent data access patterns**
|
||||
|
||||
### Cross-Service Communication Pattern
|
||||
|
||||
Instead of services directly accessing each other's internal data, checks should import and use client objects:
|
||||
|
||||
**❌ INCORRECT - Direct data access:**
|
||||
|
||||
```python
|
||||
# DON'T DO THIS
|
||||
from prowler.providers.aws.services.cloudtrail.cloudtrail_service import cloudtrail_service
|
||||
from prowler.providers.aws.services.s3.s3_service import s3_service
|
||||
|
||||
class cloudtrail_bucket_requires_mfa_delete(Check):
|
||||
def execute(self):
|
||||
# WRONG: Directly accessing service data
|
||||
for trail in cloudtrail_service.trails.values():
|
||||
for bucket in s3_service.buckets.values():
|
||||
# Direct access violates separation of concerns
|
||||
```
|
||||
|
||||
**✅ CORRECT - Client-based communication:**
|
||||
|
||||
```python
|
||||
# DO THIS INSTEAD
|
||||
from prowler.providers.aws.services.cloudtrail.cloudtrail_client import cloudtrail_client
|
||||
from prowler.providers.aws.services.s3.s3_client import s3_client
|
||||
|
||||
class cloudtrail_bucket_requires_mfa_delete(Check):
|
||||
def execute(self):
|
||||
# CORRECT: Using client objects for cross-service communication
|
||||
for trail in cloudtrail_client.trails.values():
|
||||
trail_bucket = trail.s3_bucket
|
||||
for bucket in s3_client.buckets.values():
|
||||
if trail_bucket == bucket.name:
|
||||
# Use bucket properties through s3_client
|
||||
if bucket.mfa_delete:
|
||||
# Implementation logic
|
||||
```
|
||||
|
||||
### Real-World Example: CloudTrail + S3 Integration
|
||||
|
||||
This example demonstrates how CloudTrail checks validate S3 bucket configurations:
|
||||
|
||||
```python
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.cloudtrail.cloudtrail_client import cloudtrail_client
|
||||
from prowler.providers.aws.services.s3.s3_client import s3_client
|
||||
|
||||
class cloudtrail_bucket_requires_mfa_delete(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
if cloudtrail_client.trails is not None:
|
||||
for trail in cloudtrail_client.trails.values():
|
||||
if trail.is_logging:
|
||||
trail_bucket_is_in_account = False
|
||||
trail_bucket = trail.s3_bucket
|
||||
|
||||
# Cross-service communication: CloudTrail check uses S3 client
|
||||
for bucket in s3_client.buckets.values():
|
||||
if trail_bucket == bucket.name:
|
||||
trail_bucket_is_in_account = True
|
||||
if bucket.mfa_delete:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Trail {trail.name} bucket ({trail_bucket}) has MFA delete enabled."
|
||||
|
||||
# Handle cross-account scenarios
|
||||
if not trail_bucket_is_in_account:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Trail {trail.name} bucket ({trail_bucket}) is a cross-account bucket or out of Prowler's audit scope, please check it manually."
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
```
|
||||
|
||||
**Key Benefits:**
|
||||
|
||||
- **CloudTrail service** only contains CloudTrail-specific data (trails, configurations)
|
||||
- **S3 service** only contains S3-specific data (buckets, policies, ACLs)
|
||||
- **Check logic** orchestrates between services using their public client interfaces
|
||||
- **Cross-account detection** is handled gracefully when resources span accounts
|
||||
|
||||
### Service Consolidation Guidelines
|
||||
|
||||
**When to combine services in the same file:**
|
||||
|
||||
Implement multiple services as **separate classes in the same file** when two services are **practically the same** or one is a **direct extension** of another.
|
||||
|
||||
**Example: S3 and S3Control**
|
||||
|
||||
S3Control is an extension of S3 that provides account-level controls and access points. Both are implemented in `s3_service.py`:
|
||||
|
||||
```python
|
||||
# File: prowler/providers/aws/services/s3/s3_service.py
|
||||
|
||||
class S3(AWSService):
|
||||
"""Standard S3 service for bucket operations"""
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.buckets = {}
|
||||
self.regions_with_buckets = []
|
||||
|
||||
# S3-specific initialization
|
||||
self._list_buckets(provider)
|
||||
self._get_bucket_versioning()
|
||||
# ... other S3-specific operations
|
||||
|
||||
class S3Control(AWSService):
|
||||
"""S3Control service for account-level and access point operations"""
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.account_public_access_block = None
|
||||
self.access_points = {}
|
||||
|
||||
# S3Control-specific initialization
|
||||
self._get_public_access_block()
|
||||
self._list_access_points()
|
||||
# ... other S3Control-specific operations
|
||||
```
|
||||
|
||||
**Separate client files:**
|
||||
|
||||
```python
|
||||
# File: prowler/providers/aws/services/s3/s3_client.py
|
||||
from prowler.providers.aws.services.s3.s3_service import S3
|
||||
s3_client = S3(Provider.get_global_provider())
|
||||
|
||||
# File: prowler/providers/aws/services/s3/s3control_client.py
|
||||
from prowler.providers.aws.services.s3.s3_service import S3Control
|
||||
s3control_client = S3Control(Provider.get_global_provider())
|
||||
```
|
||||
|
||||
**When NOT to consolidate services:**
|
||||
|
||||
Keep services separate when they:
|
||||
|
||||
- **Operate on different resource types** (EC2 vs RDS)
|
||||
- **Have different authentication mechanisms** (different API endpoints)
|
||||
- **Serve different operational domains** (IAM vs CloudTrail)
|
||||
- **Have different regional behaviors** (global vs regional services)
|
||||
|
||||
### Cross-Service Dependencies Guidelines
|
||||
|
||||
**1. Always use client imports:**
|
||||
|
||||
```python
|
||||
# Correct pattern
|
||||
from prowler.providers.aws.services.service_a.service_a_client import service_a_client
|
||||
from prowler.providers.aws.services.service_b.service_b_client import service_b_client
|
||||
```
|
||||
|
||||
**2. Handle missing resources gracefully:**
|
||||
|
||||
```python
|
||||
# Handle cross-service scenarios
|
||||
resource_found_in_account = False
|
||||
for external_resource in other_service_client.resources.values():
|
||||
if target_resource_id == external_resource.id:
|
||||
resource_found_in_account = True
|
||||
# Process found resource
|
||||
break
|
||||
|
||||
if not resource_found_in_account:
|
||||
# Handle cross-account or missing resource scenarios
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = "Resource is cross-account or out of audit scope"
|
||||
```
|
||||
|
||||
**3. Document cross-service dependencies:**
|
||||
|
||||
```python
|
||||
class check_with_dependencies(Check):
|
||||
"""
|
||||
Check Description
|
||||
|
||||
Dependencies:
|
||||
- service_a_client: For primary resource information
|
||||
- service_b_client: For related resource validation
|
||||
- service_c_client: For policy analysis
|
||||
"""
|
||||
```
|
||||
|
||||
## Regional Service Implementation
|
||||
|
||||
When implementing services for regional providers (like AWS, Azure, GCP), special considerations are needed to handle resource discovery across multiple geographic locations. This section provides a complete guide using AWS as the reference example.
|
||||
|
||||
### Regional vs Non-Regional Services
|
||||
|
||||
**Regional Services:** Require iteration across multiple geographic locations where resources may exist (e.g., EC2 instances, VPC, RDS databases).
|
||||
|
||||
**Non-Regional/Global Services:** Operate at a global or tenant level without regional concepts (e.g., IAM users, Route53 hosted zones).
|
||||
|
||||
### AWS Regional Implementation Example
|
||||
|
||||
AWS is the perfect example of a regional provider. Here's how Prowler handles AWS's regional architecture:
|
||||
|
||||
|
||||
```python
|
||||
# File: prowler/providers/aws/services/ec2/ec2_service.py
|
||||
class EC2(AWSService):
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.instances = {}
|
||||
self.security_groups = {}
|
||||
|
||||
# Regional resource discovery across all AWS regions
|
||||
self.__threading_call__(self._describe_instances)
|
||||
self.__threading_call__(self._describe_security_groups)
|
||||
|
||||
def _describe_instances(self, regional_client):
|
||||
"""Discover EC2 instances in a specific region"""
|
||||
try:
|
||||
describe_instances_paginator = regional_client.get_paginator("describe_instances")
|
||||
for page in describe_instances_paginator.paginate():
|
||||
for reservation in page["Reservations"]:
|
||||
for instance in reservation["Instances"]:
|
||||
# Each instance includes its region
|
||||
self.instances[instance["InstanceId"]] = Instance(
|
||||
id=instance["InstanceId"],
|
||||
region=regional_client.region,
|
||||
state=instance["State"]["Name"],
|
||||
# ... other properties
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to describe instances in {regional_client.region}: {error}")
|
||||
```
|
||||
|
||||
#### Regional Check Execution
|
||||
|
||||
```python
|
||||
# File: prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.py
|
||||
class ec2_instance_public_ip(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
|
||||
# Automatically iterates across ALL AWS regions where instances exist
|
||||
for instance in ec2_client.instances.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=instance)
|
||||
report.region = instance.region # Critical: region attribution
|
||||
report.resource_arn = f"arn:aws:ec2:{instance.region}:{instance.account_id}:instance/{instance.id}"
|
||||
|
||||
if instance.public_ip:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Instance {instance.id} in {instance.region} has public IP {instance.public_ip}"
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Instance {instance.id} in {instance.region} does not have a public IP"
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
```
|
||||
|
||||
#### Key AWS Regional Features
|
||||
|
||||
**Region-Specific ARNs:**
|
||||
|
||||
```
|
||||
arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0
|
||||
arn:aws:s3:eu-west-1:123456789012:bucket/my-bucket
|
||||
arn:aws:rds:ap-southeast-2:123456789012:db:my-database
|
||||
```
|
||||
|
||||
**Parallel Processing:**
|
||||
|
||||
- Each region processed independently in separate threads
|
||||
- Failed regions don't affect other regions
|
||||
- User can filter specific regions: `-f us-east-1`
|
||||
|
||||
**Global vs Regional Services:**
|
||||
|
||||
- **Regional**: EC2, RDS, VPC (require region iteration)
|
||||
- **Global**: IAM, Route53, CloudFront (single `us-east-1` call)
|
||||
|
||||
This architecture allows Prowler to efficiently scan AWS accounts with resources spread across multiple regions while maintaining performance and error isolation.
|
||||
|
||||
### Regional Service Best Practices
|
||||
|
||||
1. **Use Threading for Regional Discovery**: Leverage the `__threading_call__` method to parallelize resource discovery across regions
|
||||
2. **Store Region Information**: Always include region metadata in resource objects for proper attribution
|
||||
3. **Handle Regional Failures Gracefully**: Ensure that failures in one region don't affect others
|
||||
4. **Optimize for Performance**: Use paginated calls and efficient data structures for large-scale resource discovery
|
||||
5. **Support Region Filtering**: Allow users to limit scans to specific regions for focused audits
|
||||
|
||||
## Best Practices
|
||||
|
||||
- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized by to maximize performance and reduce scan time.
|
||||
@@ -252,3 +546,5 @@ Provider-Specific Permissions Documentation:
|
||||
- Collect and store resource tags and additional attributes to support richer checks and reporting.
|
||||
- Leverage shared utility helpers for session setup, identifier parsing, and other cross-cutting concerns to avoid code duplication. This kind of code is typically stored in a `lib` folder in the service folder.
|
||||
- Keep code modular, maintainable, and well-documented for ease of extension and troubleshooting.
|
||||
- **Each service should contain only information unique to that specific service** - use client objects for cross-service communication.
|
||||
- **Handle cross-account and missing resources gracefully** when checks span multiple services.
|
||||
|
||||
+2
-1
@@ -114,7 +114,8 @@
|
||||
"group": "Tutorials",
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-app-sso-entra",
|
||||
"user-guide/tutorials/bulk-provider-provisioning"
|
||||
"user-guide/tutorials/bulk-provider-provisioning",
|
||||
"user-guide/tutorials/aws-organizations-bulk-provisioning"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,491 @@
|
||||
---
|
||||
title: 'AWS Organizations Bulk Provisioning in Prowler'
|
||||
---
|
||||
|
||||
Prowler offers an automated tool to discover and provision all AWS accounts within an AWS Organization. This streamlines onboarding for organizations managing multiple AWS accounts by automatically generating the configuration needed for bulk provisioning.
|
||||
|
||||
The tool, `aws_org_generator.py`, complements the [Bulk Provider Provisioning](./bulk-provider-provisioning) tool and is available in the Prowler repository at: [util/prowler-bulk-provisioning](https://github.com/prowler-cloud/prowler/tree/master/util/prowler-bulk-provisioning)
|
||||
|
||||
<Note>
|
||||
Native support for bulk provisioning AWS Organizations and similar multi-account structures directly in the Prowler UI/API is on the official roadmap.
|
||||
|
||||
Track progress and vote for this feature at: [Bulk Provisioning in the UI/API for AWS Organizations](https://roadmap.prowler.com/p/builk-provisioning-in-the-uiapi-for-aws-organizations-and-alike)
|
||||
</Note>
|
||||
|
||||
{/* TODO: Add screenshot of the tool in action */}
|
||||
|
||||
## Overview
|
||||
|
||||
The AWS Organizations Bulk Provisioning tool simplifies multi-account onboarding by:
|
||||
|
||||
* Automatically discovering all active accounts in an AWS Organization
|
||||
* Generating YAML configuration files for bulk provisioning
|
||||
* Supporting account filtering and custom role configurations
|
||||
* Eliminating manual entry of account IDs and role ARNs
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Requirements
|
||||
|
||||
* Python 3.7 or higher
|
||||
* AWS credentials with Organizations read access
|
||||
* ProwlerRole (or custom role) deployed across all target accounts
|
||||
* Prowler API key (from Prowler Cloud or self-hosted Prowler App)
|
||||
* For self-hosted Prowler App, remember to [point to your API base URL](./bulk-provider-provisioning#custom-api-endpoints)
|
||||
* Learn how to create API keys: [Prowler App API Keys](../providers/prowler-app-api-keys)
|
||||
|
||||
### Deploying ProwlerRole Across AWS Organizations
|
||||
|
||||
Before using the AWS Organizations generator, deploy the ProwlerRole across all accounts in the organization using CloudFormation StackSets.
|
||||
|
||||
<Note>
|
||||
**Follow the official documentation:**
|
||||
[Deploying Prowler IAM Roles Across AWS Organizations](../providers/aws/organizations#deploying-prowler-iam-roles-across-aws-organizations)
|
||||
|
||||
**Key points:**
|
||||
|
||||
* Use CloudFormation StackSets from the management account
|
||||
* Deploy to all organizational units (OUs) or specific OUs
|
||||
* Use an external ID for enhanced security
|
||||
* Ensure the role has necessary permissions for Prowler scans
|
||||
</Note>
|
||||
|
||||
### Installation
|
||||
|
||||
Clone the repository and install required dependencies:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/prowler-cloud/prowler.git
|
||||
cd prowler/util/prowler-bulk-provisioning
|
||||
pip install -r requirements-aws-org.txt
|
||||
```
|
||||
|
||||
### AWS Credentials Setup
|
||||
|
||||
Configure AWS credentials with Organizations read access:
|
||||
|
||||
* **Management account credentials**, or
|
||||
* **Delegated administrator account** with `organizations:ListAccounts` permission
|
||||
|
||||
Required IAM permissions:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"organizations:ListAccounts",
|
||||
"organizations:DescribeOrganization"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Prowler API Key Setup
|
||||
|
||||
Configure your Prowler API key:
|
||||
|
||||
```bash
|
||||
export PROWLER_API_KEY="pk_example-api-key"
|
||||
```
|
||||
|
||||
To create an API key:
|
||||
|
||||
1. Log in to Prowler Cloud or Prowler App
|
||||
2. Click **Profile** → **Account**
|
||||
3. Click **Create API Key**
|
||||
4. Provide a descriptive name and optionally set an expiration date
|
||||
5. Copy the generated API key (it will only be shown once)
|
||||
|
||||
For detailed instructions, see: [Prowler App API Keys](../providers/prowler-app-api-keys)
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Generate Configuration for All Accounts
|
||||
|
||||
To generate a YAML configuration file for all active accounts in the organization:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py -o aws-accounts.yaml --external-id prowler-ext-id-2024
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
1. Lists all ACTIVE accounts in the organization
|
||||
2. Generates YAML entries for each account
|
||||
3. Saves the configuration to `aws-accounts.yaml`
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
Fetching accounts from AWS Organizations...
|
||||
Found 47 active accounts in organization
|
||||
Generated configuration for 47 accounts
|
||||
|
||||
Configuration written to: aws-accounts.yaml
|
||||
|
||||
Next steps:
|
||||
1. Review the generated file: cat aws-accounts.yaml | head -n 20
|
||||
2. Run bulk provisioning: python prowler_bulk_provisioning.py aws-accounts.yaml
|
||||
```
|
||||
|
||||
### Review Generated Configuration
|
||||
|
||||
Review the generated YAML configuration:
|
||||
|
||||
```bash
|
||||
head -n 20 aws-accounts.yaml
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
|
||||
```yaml
|
||||
- provider: aws
|
||||
uid: '111111111111'
|
||||
alias: Production-Account
|
||||
auth_method: role
|
||||
credentials:
|
||||
role_arn: arn:aws:iam::111111111111:role/ProwlerRole
|
||||
external_id: prowler-ext-id-2024
|
||||
|
||||
- provider: aws
|
||||
uid: '222222222222'
|
||||
alias: Development-Account
|
||||
auth_method: role
|
||||
credentials:
|
||||
role_arn: arn:aws:iam::222222222222:role/ProwlerRole
|
||||
external_id: prowler-ext-id-2024
|
||||
```
|
||||
|
||||
### Dry Run Mode
|
||||
|
||||
Test the configuration without writing a file:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
--external-id prowler-ext-id-2024 \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Using a Specific AWS Profile
|
||||
|
||||
Specify an AWS profile when multiple profiles are configured:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--profile org-management-admin \
|
||||
--external-id prowler-ext-id-2024
|
||||
```
|
||||
|
||||
### Excluding Specific Accounts
|
||||
|
||||
Exclude the management account or other accounts from provisioning:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--external-id prowler-ext-id-2024 \
|
||||
--exclude 123456789012,210987654321
|
||||
```
|
||||
|
||||
Common exclusion scenarios:
|
||||
|
||||
* Management account (requires different permissions)
|
||||
* Break-glass accounts (emergency access)
|
||||
* Suspended or archived accounts
|
||||
|
||||
### Including Only Specific Accounts
|
||||
|
||||
Generate configuration for specific accounts only:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--external-id prowler-ext-id-2024 \
|
||||
--include 111111111111,222222222222,333333333333
|
||||
```
|
||||
|
||||
### Custom Role Name
|
||||
|
||||
Specify a custom role name if not using the default `ProwlerRole`:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--role-name ProwlerExecutionRole \
|
||||
--external-id prowler-ext-id-2024
|
||||
```
|
||||
|
||||
### Custom Alias Format
|
||||
|
||||
Customize account aliases using template variables:
|
||||
|
||||
```bash
|
||||
# Use account name and ID
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--alias-format "{name}-{id}" \
|
||||
--external-id prowler-ext-id-2024
|
||||
|
||||
# Use email prefix
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--alias-format "{email}" \
|
||||
--external-id prowler-ext-id-2024
|
||||
```
|
||||
|
||||
Available template variables:
|
||||
|
||||
* `{name}` - Account name
|
||||
* `{id}` - Account ID
|
||||
* `{email}` - Account email
|
||||
|
||||
### Additional Role Assumption Options
|
||||
|
||||
Configure optional role assumption parameters:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--role-name ProwlerRole \
|
||||
--external-id prowler-ext-id-2024 \
|
||||
--session-name prowler-scan-session \
|
||||
--duration-seconds 3600
|
||||
```
|
||||
|
||||
## Complete Workflow Example
|
||||
|
||||
<Steps>
|
||||
<Step title="Deploy ProwlerRole Using StackSets">
|
||||
1. Log in to the AWS management account
|
||||
2. Open CloudFormation → StackSets
|
||||
3. Create a new StackSet using the [Prowler role template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml)
|
||||
4. Deploy to all organizational units
|
||||
5. Use a unique external ID (e.g., `prowler-org-2024-abc123`)
|
||||
|
||||
{/* TODO: Add screenshot of CloudFormation StackSets deployment */}
|
||||
</Step>
|
||||
|
||||
<Step title="Generate YAML Configuration">
|
||||
Configure AWS credentials and generate the YAML file:
|
||||
|
||||
```bash
|
||||
# Using management account credentials
|
||||
export AWS_PROFILE=org-management
|
||||
|
||||
# Generate configuration
|
||||
python aws_org_generator.py \
|
||||
-o aws-org-accounts.yaml \
|
||||
--external-id prowler-org-2024-abc123 \
|
||||
--exclude 123456789012
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
Fetching accounts from AWS Organizations...
|
||||
Using AWS profile: org-management
|
||||
Found 47 active accounts in organization
|
||||
Generated configuration for 46 accounts
|
||||
|
||||
Configuration written to: aws-org-accounts.yaml
|
||||
|
||||
Next steps:
|
||||
1. Review the generated file: cat aws-org-accounts.yaml | head -n 20
|
||||
2. Run bulk provisioning: python prowler_bulk_provisioning.py aws-org-accounts.yaml
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Review Generated Configuration">
|
||||
Verify the generated YAML configuration:
|
||||
|
||||
```bash
|
||||
# View first 20 lines
|
||||
head -n 20 aws-org-accounts.yaml
|
||||
|
||||
# Check for unexpected accounts
|
||||
grep "uid:" aws-org-accounts.yaml
|
||||
|
||||
# Verify role ARNs
|
||||
grep "role_arn:" aws-org-accounts.yaml | head -5
|
||||
|
||||
# Count accounts
|
||||
grep "provider: aws" aws-org-accounts.yaml | wc -l
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Run Bulk Provisioning">
|
||||
Provision all accounts to Prowler Cloud or Prowler App:
|
||||
|
||||
```bash
|
||||
# Set Prowler API key
|
||||
export PROWLER_API_KEY="pk_example-api-key"
|
||||
|
||||
# Run bulk provisioning with connection testing
|
||||
python prowler_bulk_provisioning.py aws-org-accounts.yaml
|
||||
```
|
||||
|
||||
**With custom options:**
|
||||
|
||||
```bash
|
||||
python prowler_bulk_provisioning.py aws-org-accounts.yaml \
|
||||
--concurrency 10 \
|
||||
--timeout 120
|
||||
```
|
||||
|
||||
**Successful output:**
|
||||
|
||||
```
|
||||
[1] ✅ Created provider (id=db9a8985-f9ec-4dd8-b5a0-e05ab3880bed)
|
||||
[1] ✅ Created secret (id=466f76c6-5878-4602-a4bc-13f9522c1fd2)
|
||||
[1] ✅ Connection test: Connected
|
||||
|
||||
[2] ✅ Created provider (id=7a99f789-0cf5-4329-8279-2d443a962676)
|
||||
[2] ✅ Created secret (id=c5702180-f7c4-40fd-be0e-f6433479b126)
|
||||
[2] ✅ Connection test: Connected
|
||||
|
||||
Done. Success: 47 Failures: 0
|
||||
```
|
||||
|
||||
{/* TODO: Add screenshot of successful bulk provisioning output */}
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Full Command-Line Options
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
-o OUTPUT_FILE \
|
||||
--role-name ROLE_NAME \
|
||||
--external-id EXTERNAL_ID \
|
||||
--session-name SESSION_NAME \
|
||||
--duration-seconds SECONDS \
|
||||
--alias-format FORMAT \
|
||||
--exclude ACCOUNT_IDS \
|
||||
--include ACCOUNT_IDS \
|
||||
--profile AWS_PROFILE \
|
||||
--region AWS_REGION \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "No AWS credentials found"
|
||||
|
||||
**Solution:** Configure AWS credentials using one of these methods:
|
||||
|
||||
```bash
|
||||
# Method 1: AWS CLI configure
|
||||
aws configure
|
||||
|
||||
# Method 2: Environment variables
|
||||
export AWS_ACCESS_KEY_ID=your-key-id
|
||||
export AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
|
||||
# Method 3: Use AWS profile
|
||||
export AWS_PROFILE=org-management
|
||||
```
|
||||
|
||||
### Error: "Access denied to AWS Organizations API"
|
||||
|
||||
**Cause:** Current credentials don't have permission to list organization accounts.
|
||||
|
||||
**Solution:**
|
||||
|
||||
* Ensure management account credentials are used
|
||||
* Verify IAM permissions include `organizations:ListAccounts`
|
||||
* Check IAM policies for Organizations access
|
||||
|
||||
### Error: "AWS Organizations is not enabled"
|
||||
|
||||
**Cause:** The account is not part of an organization.
|
||||
|
||||
**Solution:** This tool requires an AWS Organization. Create one in the AWS Organizations console or use standard bulk provisioning for standalone accounts.
|
||||
|
||||
### No Accounts Generated After Filters
|
||||
|
||||
**Cause:** All accounts were filtered out by `--exclude` or `--include` options.
|
||||
|
||||
**Solution:** Review filter options and verify account IDs are correct:
|
||||
|
||||
```bash
|
||||
# List all accounts in organization
|
||||
aws organizations list-accounts --query "Accounts[?Status=='ACTIVE'].[Id,Name]" --output table
|
||||
```
|
||||
|
||||
### Connection Test Failures During Bulk Provisioning
|
||||
|
||||
**Cause:** ProwlerRole may not be deployed correctly or credentials are invalid.
|
||||
|
||||
**Solution:**
|
||||
|
||||
* Verify StackSet deployment status in CloudFormation
|
||||
* Check role trust policy includes correct external ID
|
||||
* Test role assumption manually:
|
||||
|
||||
```bash
|
||||
aws sts assume-role \
|
||||
--role-arn arn:aws:iam::123456789012:role/ProwlerRole \
|
||||
--role-session-name test \
|
||||
--external-id prowler-ext-id-2024
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Use External ID
|
||||
|
||||
Always use an external ID when assuming cross-account roles:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--external-id $(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
```
|
||||
|
||||
The external ID must match the one configured in the ProwlerRole trust policy across all accounts.
|
||||
|
||||
### Exclude Sensitive Accounts
|
||||
|
||||
Exclude accounts that shouldn't be scanned or require special handling:
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py \
|
||||
-o aws-accounts.yaml \
|
||||
--external-id prowler-ext-id \
|
||||
--exclude 123456789012,111111111111 # management, break-glass accounts
|
||||
```
|
||||
|
||||
### Review Generated Configuration
|
||||
|
||||
Always review the generated YAML before provisioning:
|
||||
|
||||
```bash
|
||||
# Check for unexpected accounts
|
||||
grep "uid:" aws-org-accounts.yaml
|
||||
|
||||
# Verify role ARNs
|
||||
grep "role_arn:" aws-org-accounts.yaml | head -5
|
||||
|
||||
# Count accounts
|
||||
grep "provider: aws" aws-org-accounts.yaml | wc -l
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Bulk Provider Provisioning" icon="terminal" href="/user-guide/tutorials/bulk-provider-provisioning">
|
||||
Learn how to bulk provision providers in Prowler.
|
||||
</Card>
|
||||
<Card title="Prowler App" icon="pen-to-square" href="/user-guide/tutorials/prowler-app">
|
||||
Detailed instructions on how to use Prowler.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -17,14 +17,18 @@ The Bulk Provider Provisioning tool automates the creation of cloud providers in
|
||||
* Testing connections to verify successful authentication
|
||||
* Processing multiple providers concurrently for efficiency
|
||||
|
||||
<Tip>
|
||||
**Using AWS Organizations?** For organizations with many AWS accounts, use the automated [AWS Organizations Bulk Provisioning](./aws-organizations-bulk-provisioning) tool to automatically discover and generate configuration for all accounts in your organization.
|
||||
</Tip>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Requirements
|
||||
|
||||
* Python 3.7 or higher
|
||||
* Prowler API token (from Prowler Cloud or self-hosted Prowler App)
|
||||
* Prowler API key (from Prowler Cloud or self-hosted Prowler App)
|
||||
* For self-hosted Prowler App, remember to [point to your API base URL](#custom-api-endpoints)
|
||||
* Learn how to create API keys: [Prowler App API Keys](../providers/prowler-app-api-keys)
|
||||
* Authentication credentials for target cloud providers
|
||||
|
||||
### Installation
|
||||
@@ -39,28 +43,21 @@ pip install -r requirements.txt
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
Configure your Prowler API token:
|
||||
Configure your Prowler API key:
|
||||
|
||||
```bash
|
||||
export PROWLER_API_TOKEN="your-prowler-api-token"
|
||||
export PROWLER_API_KEY="pk_example-api-key"
|
||||
```
|
||||
|
||||
To obtain an API token programmatically:
|
||||
To create an API key:
|
||||
|
||||
```bash
|
||||
export PROWLER_API_TOKEN=$(curl --location 'https://api.prowler.com/api/v1/tokens' \
|
||||
--header 'Content-Type: application/vnd.api+json' \
|
||||
--header 'Accept: application/vnd.api+json' \
|
||||
--data-raw '{
|
||||
"data": {
|
||||
"type": "tokens",
|
||||
"attributes": {
|
||||
"email": "your@email.com",
|
||||
"password": "your-password"
|
||||
}
|
||||
}
|
||||
}' | jq -r .data.attributes.access)
|
||||
```
|
||||
1. Log in to Prowler Cloud or Prowler App
|
||||
2. Click **Profile** → **Account**
|
||||
3. Click **Create API Key**
|
||||
4. Provide a descriptive name and optionally set an expiration date
|
||||
5. Copy the generated API key (it will only be shown once)
|
||||
|
||||
For detailed instructions, see: [Prowler App API Keys](../providers/prowler-app-api-keys)
|
||||
|
||||
## Configuration File Structure
|
||||
|
||||
@@ -340,11 +337,11 @@ Done. Success: 2 Failures: 0
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Invalid API Token
|
||||
### Invalid API Key
|
||||
|
||||
```
|
||||
Error: 401 Unauthorized
|
||||
Solution: Verify your PROWLER_API_TOKEN or --token parameter
|
||||
Solution: Verify your PROWLER_API_KEY environment variable or --api-key parameter
|
||||
```
|
||||
|
||||
### Network Timeouts
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
## [0.1.0] (Prowler UNRELEASED)
|
||||
## [0.1.0] (Prowler 5.13.0)
|
||||
|
||||
### Added
|
||||
- Initial release of Prowler MCP Server [(#8695)](https://github.com/prowler-cloud/prowler/pull/8695)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [v5.13.0] (Prowler UNRELEASED)
|
||||
## [v5.13.0] (Prowler v5.13.0)
|
||||
|
||||
### Added
|
||||
- Support for AdditionalURLs in outputs [(#8651)](https://github.com/prowler-cloud/prowler/pull/8651)
|
||||
@@ -17,6 +17,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Oracle Cloud provider with CIS 3.0 benchmark [(#8893)](https://github.com/prowler-cloud/prowler/pull/8893)
|
||||
- Support for Atlassian Document Format (ADF) in Jira integration [(#8878)](https://github.com/prowler-cloud/prowler/pull/8878)
|
||||
- Add Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000)
|
||||
- Improve Provider documentation guide [(#8430)](https://github.com/prowler-cloud/prowler/pull/8430)
|
||||
- `cloudstorage_bucket_lifecycle_management_enabled` check for GCP provider [(#8936)](https://github.com/prowler-cloud/prowler/pull/8936)
|
||||
|
||||
### Changed
|
||||
@@ -51,13 +52,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Prowler ThreatScore scoring calculation CLI [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
|
||||
- Add missing attributes for Mitre Attack AWS, Azure and GCP [(#8907)](https://github.com/prowler-cloud/prowler/pull/8907)
|
||||
- Fix KeyError in CloudSQL and Monitoring services in GCP provider [(#8909)](https://github.com/prowler-cloud/prowler/pull/8909)
|
||||
- Fix Value Errors in Entra service for M365 provider [(#8919)](https://github.com/prowler-cloud/prowler/pull/8919)
|
||||
- Fix ResourceName in GCP provider [(#8928)](https://github.com/prowler-cloud/prowler/pull/8928)
|
||||
|
||||
---
|
||||
|
||||
## [v5.12.4] (Prowler UNRELEASED)
|
||||
|
||||
### Fixed
|
||||
- Fix KeyError in `elb_ssl_listeners_use_acm_certificate` check and handle None cluster version in `eks_cluster_uses_a_supported_version` check [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791)
|
||||
- Fix file extension parsing for compliance reports [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791)
|
||||
- Added user pagination to Entra and Admincenter services [(#8858)](https://github.com/prowler-cloud/prowler/pull/8858)
|
||||
|
||||
+19
-2
@@ -2,7 +2,6 @@ from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.entra.entra_client import entra_client
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
AdminRoles,
|
||||
AuthenticationStrength,
|
||||
ConditionalAccessPolicyState,
|
||||
)
|
||||
|
||||
@@ -47,7 +46,25 @@ class entra_admin_users_phishing_resistant_mfa_enabled(Check):
|
||||
if (
|
||||
policy.grant_controls.authentication_strength is not None
|
||||
and policy.grant_controls.authentication_strength
|
||||
== AuthenticationStrength.PHISHING_RESISTANT_MFA
|
||||
!= "Multifactor authentication"
|
||||
and policy.grant_controls.authentication_strength != "Passwordless MFA"
|
||||
and policy.grant_controls.authentication_strength
|
||||
!= "Phishing-resistant MFA"
|
||||
):
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=policy,
|
||||
resource_name=policy.display_name,
|
||||
resource_id=policy.id,
|
||||
)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Conditional Access Policy '{policy.display_name}' has a custom authentication strength, review it is Phishing-resistant MFA."
|
||||
continue
|
||||
|
||||
if (
|
||||
policy.grant_controls.authentication_strength is not None
|
||||
and policy.grant_controls.authentication_strength
|
||||
== "Phishing-resistant MFA"
|
||||
):
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
|
||||
@@ -253,9 +253,7 @@ class Entra(M365Service):
|
||||
)
|
||||
),
|
||||
authentication_strength=(
|
||||
AuthenticationStrength(
|
||||
policy.grant_controls.authentication_strength.display_name
|
||||
)
|
||||
policy.grant_controls.authentication_strength.display_name
|
||||
if policy.grant_controls is not None
|
||||
and policy.grant_controls.authentication_strength
|
||||
is not None
|
||||
@@ -455,6 +453,7 @@ class ConditionalAccessPolicyState(Enum):
|
||||
|
||||
class UserAction(Enum):
|
||||
REGISTER_SECURITY_INFO = "urn:user:registersecurityinfo"
|
||||
REGISTER_DEVICE = "urn:user:registerdevice"
|
||||
|
||||
|
||||
class ApplicationsConditions(BaseModel):
|
||||
@@ -523,11 +522,19 @@ class SessionControls(BaseModel):
|
||||
|
||||
|
||||
class ConditionalAccessGrantControl(Enum):
|
||||
"""
|
||||
Built-in grant controls for Conditional Access policies.
|
||||
Reference: https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessgrantcontrols
|
||||
"""
|
||||
|
||||
MFA = "mfa"
|
||||
BLOCK = "block"
|
||||
DOMAIN_JOINED_DEVICE = "domainJoinedDevice"
|
||||
PASSWORD_CHANGE = "passwordChange"
|
||||
COMPLIANT_DEVICE = "compliantDevice"
|
||||
APPROVED_APPLICATION = "approvedApplication"
|
||||
COMPLIANT_APPLICATION = "compliantApplication"
|
||||
TERMS_OF_USE = "termsOfUse"
|
||||
|
||||
|
||||
class GrantControlOperator(Enum):
|
||||
@@ -535,16 +542,10 @@ class GrantControlOperator(Enum):
|
||||
OR = "OR"
|
||||
|
||||
|
||||
class AuthenticationStrength(Enum):
|
||||
MFA = "Multifactor authentication"
|
||||
PASSWORDLESS_MFA = "Passwordless MFA"
|
||||
PHISHING_RESISTANT_MFA = "Phishing-resistant MFA"
|
||||
|
||||
|
||||
class GrantControls(BaseModel):
|
||||
built_in_controls: List[ConditionalAccessGrantControl]
|
||||
operator: GrantControlOperator
|
||||
authentication_strength: Optional[AuthenticationStrength]
|
||||
authentication_strength: Optional[str]
|
||||
|
||||
|
||||
class ConditionalAccessPolicy(BaseModel):
|
||||
|
||||
+3
-4
@@ -3,7 +3,6 @@ from uuid import uuid4
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
ApplicationsConditions,
|
||||
AuthenticationStrength,
|
||||
ConditionalAccessGrantControl,
|
||||
ConditionalAccessPolicyState,
|
||||
Conditions,
|
||||
@@ -114,7 +113,7 @@ class Test_entra_admin_users_phishing_resistant_mfa_enabled:
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
|
||||
authentication_strength="Phishing-resistant MFA",
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
@@ -206,7 +205,7 @@ class Test_entra_admin_users_phishing_resistant_mfa_enabled:
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
|
||||
authentication_strength="Phishing-resistant MFA",
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
@@ -301,7 +300,7 @@ class Test_entra_admin_users_phishing_resistant_mfa_enabled:
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
|
||||
authentication_strength="Phishing-resistant MFA",
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
|
||||
@@ -7,7 +7,6 @@ from prowler.providers.m365.services.entra.entra_service import (
|
||||
AdminConsentPolicy,
|
||||
AdminRoles,
|
||||
ApplicationsConditions,
|
||||
AuthenticationStrength,
|
||||
AuthorizationPolicy,
|
||||
AuthPolicyRoles,
|
||||
ConditionalAccessGrantControl,
|
||||
@@ -75,7 +74,7 @@ async def mock_entra_get_conditional_access_policies(_):
|
||||
ConditionalAccessGrantControl.COMPLIANT_DEVICE,
|
||||
],
|
||||
operator=GrantControlOperator.OR,
|
||||
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
|
||||
authentication_strength="Phishing-resistant MFA",
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
@@ -226,7 +225,7 @@ class Test_Entra_Service:
|
||||
ConditionalAccessGrantControl.COMPLIANT_DEVICE,
|
||||
],
|
||||
operator=GrantControlOperator.OR,
|
||||
authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA,
|
||||
authentication_strength="Phishing-resistant MFA",
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.13.0] (Prowler UNRELEASED)
|
||||
## [1.13.0] (Prowler v5.13.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, BellOff, ShieldCheck, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { DonutChart } from "@/components/graphs/donut-chart";
|
||||
import { DonutDataPoint } from "@/components/graphs/types";
|
||||
import {
|
||||
BaseCard,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ResourceStatsCard,
|
||||
StatsContainer,
|
||||
} from "@/components/shadcn";
|
||||
import { CardVariant } from "@/components/shadcn/card/resource-stats-card/resource-stats-card-content";
|
||||
|
||||
interface CheckFindingsProps {
|
||||
failFindingsData: {
|
||||
total: number;
|
||||
new: number;
|
||||
muted: number;
|
||||
};
|
||||
passFindingsData: {
|
||||
total: number;
|
||||
new: number;
|
||||
muted: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const CheckFindings = ({
|
||||
failFindingsData,
|
||||
passFindingsData,
|
||||
}: CheckFindingsProps) => {
|
||||
// Calculate total findings
|
||||
const totalFindings = failFindingsData.total + passFindingsData.total;
|
||||
|
||||
// Calculate percentages
|
||||
const failPercentage = Math.round(
|
||||
(failFindingsData.total / totalFindings) * 100,
|
||||
);
|
||||
const passPercentage = Math.round(
|
||||
(passFindingsData.total / totalFindings) * 100,
|
||||
);
|
||||
|
||||
// Calculate change percentages (new findings as percentage change)
|
||||
const failChange =
|
||||
failFindingsData.total > 0
|
||||
? Math.round((failFindingsData.new / failFindingsData.total) * 100)
|
||||
: 0;
|
||||
const passChange =
|
||||
passFindingsData.total > 0
|
||||
? Math.round((passFindingsData.new / passFindingsData.total) * 100)
|
||||
: 0;
|
||||
|
||||
// Mock data for DonutChart
|
||||
const donutData: DonutDataPoint[] = [
|
||||
{
|
||||
name: "Fail Findings",
|
||||
value: failFindingsData.total,
|
||||
color: "#f43f5e", // Rose-500
|
||||
percentage: Number(failPercentage),
|
||||
change: Number(failChange),
|
||||
},
|
||||
{
|
||||
name: "Pass Findings",
|
||||
value: passFindingsData.total,
|
||||
color: "#4ade80", // Green-400
|
||||
percentage: Number(passPercentage),
|
||||
change: Number(passChange),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseCard>
|
||||
{/* Header */}
|
||||
<CardHeader>
|
||||
<CardTitle>Check Findings</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
{/* DonutChart Content */}
|
||||
<CardContent className="space-y-4">
|
||||
<div className="mx-auto max-h-[200px] max-w-[200px]">
|
||||
<DonutChart
|
||||
data={donutData}
|
||||
showLegend={false}
|
||||
innerRadius={66}
|
||||
outerRadius={86}
|
||||
centerLabel={{
|
||||
value: totalFindings.toLocaleString(),
|
||||
label: "Total Findings",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer with ResourceStatsCards */}
|
||||
<StatsContainer>
|
||||
<ResourceStatsCard
|
||||
containerless
|
||||
badge={{
|
||||
icon: TriangleAlert,
|
||||
count: failFindingsData.total,
|
||||
variant: CardVariant.fail,
|
||||
}}
|
||||
label="Fail Findings"
|
||||
stats={[
|
||||
{ icon: Bell, label: `${failFindingsData.new} New` },
|
||||
{ icon: BellOff, label: `${failFindingsData.muted} Muted` },
|
||||
]}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center px-[46px]">
|
||||
<div className="h-full w-px bg-slate-300 dark:bg-[rgba(39,39,42,1)]" />
|
||||
</div>
|
||||
|
||||
<ResourceStatsCard
|
||||
containerless
|
||||
badge={{
|
||||
icon: ShieldCheck,
|
||||
count: passFindingsData.total,
|
||||
variant: CardVariant.pass,
|
||||
}}
|
||||
label="Pass Findings"
|
||||
stats={[
|
||||
{ icon: Bell, label: `${passFindingsData.new} New` },
|
||||
{ icon: BellOff, label: `${passFindingsData.muted} Muted` },
|
||||
]}
|
||||
className="flex-1"
|
||||
/>
|
||||
</StatsContainer>
|
||||
</CardContent>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getFindingsByStatus } from "@/actions/overview/overview";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { CheckFindings } from "./components/check-findings";
|
||||
|
||||
const FILTER_PREFIX = "filter[";
|
||||
|
||||
// Extract only query params that start with "filter[" for API calls
|
||||
function pickFilterParams(
|
||||
params: SearchParamsProps | undefined | null,
|
||||
): Record<string, string | string[] | undefined> {
|
||||
if (!params) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
|
||||
);
|
||||
}
|
||||
|
||||
export default async function NewOverviewPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
|
||||
return (
|
||||
<ContentLayout title="New Overview" icon="lucide:square-chart-gantt">
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-6">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
|
||||
<p className="text-zinc-400">Loading...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SSRCheckFindings searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const SSRCheckFindings = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps | undefined | null;
|
||||
}) => {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
const findingsByStatus = await getFindingsByStatus({ filters });
|
||||
|
||||
if (!findingsByStatus) {
|
||||
return (
|
||||
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
|
||||
<p className="text-zinc-400">Failed to load findings data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
fail = 0,
|
||||
pass = 0,
|
||||
muted_new = 0,
|
||||
muted_changed = 0,
|
||||
fail_new = 0,
|
||||
pass_new = 0,
|
||||
} = findingsByStatus?.data?.attributes || {};
|
||||
|
||||
const mutedTotal = muted_new + muted_changed;
|
||||
|
||||
return (
|
||||
<CheckFindings
|
||||
failFindingsData={{
|
||||
total: fail,
|
||||
new: fail_new,
|
||||
muted: mutedTotal,
|
||||
}}
|
||||
passFindingsData={{
|
||||
total: pass,
|
||||
new: pass_new,
|
||||
muted: mutedTotal,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -21,44 +21,39 @@ interface DonutChartProps {
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3 shadow-lg"
|
||||
style={{
|
||||
backgroundColor: "var(--chart-background)",
|
||||
borderColor: "var(--chart-border-emphasis)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--chart-text-primary)" }}
|
||||
>
|
||||
{data.percentage}% {data.name}
|
||||
</span>
|
||||
</div>
|
||||
{data.change !== undefined && (
|
||||
<p
|
||||
className="mt-2 text-xs"
|
||||
style={{ color: "var(--chart-text-secondary)" }}
|
||||
>
|
||||
<span className="font-bold">
|
||||
{data.change > 0 ? "+" : ""}
|
||||
{data.change}%
|
||||
</span>{" "}
|
||||
Since last scan
|
||||
</p>
|
||||
)}
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const entry = payload[0];
|
||||
const name = entry.name;
|
||||
const percentage = entry.payload?.percentage;
|
||||
const color = entry.color || entry.payload?.color;
|
||||
const change = entry.payload?.change;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800">
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-slate-600 dark:text-zinc-300">
|
||||
{percentage}%
|
||||
</span>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
<p className="mt-1 text-xs text-slate-600 dark:text-zinc-300">
|
||||
{change !== undefined && (
|
||||
<>
|
||||
<span className="font-bold">
|
||||
{change > 0 ? "+" : ""}
|
||||
{change}%
|
||||
</span>
|
||||
<span> Since Last Scan</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
@@ -72,8 +67,8 @@ const CustomLegend = ({ payload }: any) => {
|
||||
|
||||
export function DonutChart({
|
||||
data,
|
||||
innerRadius = 80,
|
||||
outerRadius = 120,
|
||||
innerRadius = 68,
|
||||
outerRadius = 86,
|
||||
showLegend = true,
|
||||
centerLabel,
|
||||
}: DonutChartProps) {
|
||||
@@ -108,7 +103,7 @@ export function DonutChart({
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[350px]"
|
||||
@@ -122,7 +117,7 @@ export function DonutChart({
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
strokeWidth={0}
|
||||
paddingAngle={2}
|
||||
paddingAngle={0}
|
||||
>
|
||||
{chartData.map((entry, index) => {
|
||||
const opacity =
|
||||
@@ -157,9 +152,9 @@ export function DonutChart({
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="text-3xl font-bold"
|
||||
className="text-3xl font-bold text-black dark:text-white"
|
||||
style={{
|
||||
fill: "var(--chart-text-primary)",
|
||||
fill: "currentColor",
|
||||
}}
|
||||
>
|
||||
{formattedValue}
|
||||
@@ -167,8 +162,9 @@ export function DonutChart({
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="text-black dark:text-white"
|
||||
style={{
|
||||
fill: "var(--chart-text-secondary)",
|
||||
fill: "currentColor",
|
||||
}}
|
||||
>
|
||||
{centerLabel.label}
|
||||
@@ -183,6 +179,6 @@ export function DonutChart({
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
{showLegend && <CustomLegend payload={legendPayload} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ChartTooltip({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-[200px] rounded-lg border p-3 shadow-lg"
|
||||
className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800"
|
||||
style={{
|
||||
borderColor: CHART_COLORS.tooltipBorder,
|
||||
backgroundColor: CHART_COLORS.tooltipBackground,
|
||||
@@ -45,15 +45,12 @@ export function ChartTooltip({
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: CHART_COLORS.textPrimary }}
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{label || data.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs" style={{ color: CHART_COLORS.textPrimary }}>
|
||||
<p className="mt-1 text-xs text-slate-900 dark:text-white">
|
||||
{typeof data.value === "number"
|
||||
? data.value.toLocaleString()
|
||||
: data.value}
|
||||
@@ -62,11 +59,8 @@ export function ChartTooltip({
|
||||
|
||||
{data.newFindings !== undefined && data.newFindings > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Bell size={14} style={{ color: "var(--chart-fail)" }} />
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: CHART_COLORS.textSecondary }}
|
||||
>
|
||||
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{data.newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
@@ -74,11 +68,8 @@ export function ChartTooltip({
|
||||
|
||||
{data.new !== undefined && data.new > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Bell size={14} style={{ color: "var(--chart-fail)" }} />
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: CHART_COLORS.textSecondary }}
|
||||
>
|
||||
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{data.new} New
|
||||
</span>
|
||||
</div>
|
||||
@@ -86,21 +77,15 @@ export function ChartTooltip({
|
||||
|
||||
{data.muted !== undefined && data.muted > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<VolumeX size={14} style={{ color: CHART_COLORS.textSecondary }} />
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: CHART_COLORS.textSecondary }}
|
||||
>
|
||||
<VolumeX size={14} className="text-slate-600 dark:text-slate-400" />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{data.muted} Muted
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.change !== undefined && (
|
||||
<p
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: CHART_COLORS.textSecondary }}
|
||||
>
|
||||
<p className="mt-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
<span className="font-bold">
|
||||
{data.change > 0 ? "+" : ""}
|
||||
{data.change}%
|
||||
@@ -125,17 +110,8 @@ export function MultiSeriesChartTooltip({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-[200px] rounded-lg border p-3 shadow-lg"
|
||||
style={{
|
||||
borderColor: CHART_COLORS.tooltipBorder,
|
||||
backgroundColor: CHART_COLORS.tooltipBackground,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="mb-2 text-sm font-semibold"
|
||||
style={{ color: CHART_COLORS.textPrimary }}
|
||||
>
|
||||
<div className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{label}
|
||||
</p>
|
||||
|
||||
@@ -145,20 +121,14 @@ export function MultiSeriesChartTooltip({
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: CHART_COLORS.textPrimary }}>
|
||||
<span className="text-xs text-slate-900 dark:text-white">
|
||||
{entry.name}:
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: CHART_COLORS.textPrimary }}
|
||||
>
|
||||
<span className="text-xs font-semibold text-slate-900 dark:text-white">
|
||||
{entry.value}
|
||||
</span>
|
||||
{entry.payload[`${entry.dataKey}_change`] && (
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: CHART_COLORS.textSecondary }}
|
||||
>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
|
||||
{entry.payload[`${entry.dataKey}_change`]}%)
|
||||
</span>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { tv } from "tailwind-variants";
|
||||
|
||||
export const title = tv({
|
||||
base: "tracking-tight inline font-semibold",
|
||||
variants: {
|
||||
color: {
|
||||
violet: "from-[#FF1CF7] to-[#b249f8]",
|
||||
yellow: "from-[#FF705B] to-[#FFB457]",
|
||||
blue: "from-[#5EA2EF] to-[#0072F5]",
|
||||
cyan: "from-[#00b7fa] to-[#01cfea]",
|
||||
green: "from-[#6FEE8D] to-[#17c964]",
|
||||
pink: "from-[#FF72E1] to-[#F54C7A]",
|
||||
foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]",
|
||||
},
|
||||
size: {
|
||||
sm: "text-3xl lg:text-4xl",
|
||||
md: "text-[2.3rem] lg:text-5xl leading-9",
|
||||
lg: "text-4xl lg:text-6xl",
|
||||
},
|
||||
fullWidth: {
|
||||
true: "w-full block",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
color: [
|
||||
"violet",
|
||||
"yellow",
|
||||
"blue",
|
||||
"cyan",
|
||||
"green",
|
||||
"pink",
|
||||
"foreground",
|
||||
],
|
||||
class: "bg-clip-text text-transparent bg-linear-to-b",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const subtitle = tv({
|
||||
base: "w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full",
|
||||
variants: {
|
||||
fullWidth: {
|
||||
true: "w-full!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
fullWidth: true,
|
||||
},
|
||||
});
|
||||
@@ -4,13 +4,18 @@ This directory contains all shadcn/ui based components for the Prowler applicati
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Example of a custom component:
|
||||
|
||||
```
|
||||
shadcn/
|
||||
├── card.tsx # shadcn Card component
|
||||
├── resource-stats-card/ # Custom ResourceStatsCard built on shadcn
|
||||
│ ├── resource-stats-card.tsx
|
||||
│ ├── resource-stats-card.example.tsx
|
||||
│ └── index.ts
|
||||
├── card/
|
||||
│ ├── base-card/
|
||||
│ │ ├── base-card.tsx
|
||||
│ ├── card/
|
||||
│ │ ├── card.tsx
|
||||
│ └── resource-stats-card/
|
||||
│ ├── resource-stats-card.tsx
|
||||
│ ├── resource-stats-card.example.tsx
|
||||
├── index.ts # Barrel exports
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Card } from "../card";
|
||||
|
||||
const baseCardVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-slate-200 bg-white dark:border-zinc-900 dark:bg-stone-950",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
interface BaseCardProps
|
||||
extends React.ComponentProps<typeof Card>,
|
||||
VariantProps<typeof baseCardVariants> {}
|
||||
|
||||
const BaseCard = ({ className, variant, ...props }: BaseCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
baseCardVariants({ variant }),
|
||||
"gap-2 px-[18px] pt-3 pb-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { BaseCard };
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -20,7 +18,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +30,10 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
className={cn(
|
||||
"my-2 text-[18px] leading-none text-slate-900 dark:text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
+8
-8
@@ -33,11 +33,11 @@ const badgeVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
[CardVariant.default]: "bg-[#535359]",
|
||||
[CardVariant.fail]: "bg-[#432232]",
|
||||
[CardVariant.pass]: "bg-[#204237]",
|
||||
[CardVariant.warning]: "bg-[#3d3520]",
|
||||
[CardVariant.info]: "bg-[#1e3a5f]",
|
||||
[CardVariant.default]: "bg-slate-100 dark:bg-[#535359]",
|
||||
[CardVariant.fail]: "bg-red-100 dark:bg-[#432232]",
|
||||
[CardVariant.pass]: "bg-green-100 dark:bg-[#204237]",
|
||||
[CardVariant.warning]: "bg-amber-100 dark:bg-[#3d3520]",
|
||||
[CardVariant.info]: "bg-blue-100 dark:bg-[#1e3a5f]",
|
||||
},
|
||||
size: {
|
||||
sm: "px-1 text-xs",
|
||||
@@ -66,7 +66,7 @@ const badgeIconVariants = cva("", {
|
||||
});
|
||||
|
||||
const labelTextVariants = cva(
|
||||
"leading-6 font-semibold text-zinc-300 dark:text-zinc-300",
|
||||
"leading-6 font-semibold text-slate-900 dark:text-zinc-300 whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
@@ -81,7 +81,7 @@ const labelTextVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const statIconVariants = cva("text-zinc-300 dark:text-zinc-300", {
|
||||
const statIconVariants = cva("text-slate-600 dark:text-zinc-300", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-2.5 w-2.5",
|
||||
@@ -95,7 +95,7 @@ const statIconVariants = cva("text-zinc-300 dark:text-zinc-300", {
|
||||
});
|
||||
|
||||
const statLabelVariants = cva(
|
||||
"leading-5 font-medium text-zinc-300 dark:text-zinc-300",
|
||||
"leading-5 font-medium text-slate-700 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
+2
-2
@@ -111,7 +111,7 @@ export const ResourceStatsCard = ({
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
<p className="text-center text-sm leading-5 font-medium text-zinc-300 dark:text-zinc-300">
|
||||
<p className="text-center text-sm leading-5 font-medium text-slate-600 dark:text-zinc-300">
|
||||
{emptyState.message}
|
||||
</p>
|
||||
</div>
|
||||
@@ -141,7 +141,7 @@ export const ResourceStatsCard = ({
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
<p className="text-center text-sm leading-5 font-medium text-zinc-300 dark:text-zinc-300">
|
||||
<p className="text-center text-sm leading-5 font-medium text-slate-600 dark:text-zinc-300">
|
||||
{emptyState.message}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatsContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StatsContainer = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StatsContainerProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex rounded-xl border border-slate-200 bg-white px-[19px] py-[9px] dark:border-[rgba(38,38,38,0.7)] dark:bg-[rgba(23,23,23,0.5)] dark:backdrop-blur-[46px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StatsContainer };
|
||||
@@ -1,21 +1,8 @@
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./card";
|
||||
export {
|
||||
ResourceStatsCard,
|
||||
ResourceStatsCardContainer,
|
||||
type ResourceStatsCardContainerProps,
|
||||
ResourceStatsCardContent,
|
||||
type ResourceStatsCardContentProps,
|
||||
ResourceStatsCardDivider,
|
||||
type ResourceStatsCardDividerProps,
|
||||
ResourceStatsCardHeader,
|
||||
type ResourceStatsCardHeaderProps,
|
||||
type ResourceStatsCardProps,
|
||||
type StatItem,
|
||||
} from "./resource-stats-card";
|
||||
export * from "./card/base-card/base-card";
|
||||
export * from "./card/card";
|
||||
export * from "./card/resource-stats-card/resource-stats-card";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-container";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-content";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-divider";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-header";
|
||||
export * from "./card/stats-container";
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export type { ResourceStatsCardProps } from "./resource-stats-card";
|
||||
export { ResourceStatsCard } from "./resource-stats-card";
|
||||
export type { ResourceStatsCardContainerProps } from "./resource-stats-card-container";
|
||||
export { ResourceStatsCardContainer } from "./resource-stats-card-container";
|
||||
export type {
|
||||
ResourceStatsCardContentProps,
|
||||
StatItem,
|
||||
} from "./resource-stats-card-content";
|
||||
export { ResourceStatsCardContent } from "./resource-stats-card-content";
|
||||
export type { ResourceStatsCardDividerProps } from "./resource-stats-card-divider";
|
||||
export { ResourceStatsCardDivider } from "./resource-stats-card-divider";
|
||||
export type { ResourceStatsCardHeaderProps } from "./resource-stats-card-header";
|
||||
export { ResourceStatsCardHeader } from "./resource-stats-card-header";
|
||||
@@ -70,6 +70,7 @@ const ChartContainer = React.forwardRef<
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
@@ -184,7 +185,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-slate-200 border-slate-200/50 bg-white px-2.5 py-1.5 text-xs shadow-xl dark:border-slate-800 dark:border-slate-800/50 dark:bg-slate-950",
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-slate-200/50 bg-white px-2.5 py-1.5 text-xs shadow-xl dark:border-slate-800/50 dark:bg-slate-950",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -86,7 +86,7 @@ export const CustomButton = React.forwardRef<
|
||||
) => (
|
||||
<Button
|
||||
as={asLink ? Link : undefined}
|
||||
href={asLink}
|
||||
{...(asLink && { href: asLink })}
|
||||
target={target}
|
||||
type={type}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -19,6 +19,7 @@ A Python script to bulk-provision cloud providers in Prowler Cloud/App via REST
|
||||
- **Flexible Authentication:** Supports various authentication methods per provider
|
||||
- **Error Handling:** Comprehensive error reporting and validation
|
||||
- **Connection Testing:** Built-in provider connection verification
|
||||
- **AWS Organizations Support:** Automated YAML generation for all accounts in an AWS Organization
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -48,32 +49,105 @@ This two-step approach follows the Prowler API design where providers and their
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Get your Prowler API token:
|
||||
- **Prowler Cloud:** Generate token at https://api.prowler.com
|
||||
- **Self-hosted Prowler App:** Generate token in your local instance
|
||||
3. Get your Prowler API key:
|
||||
- **Prowler Cloud:** Create an API key at https://api.prowler.com
|
||||
- **Self-hosted Prowler App:** Create an API key in your local instance
|
||||
- Click **Profile** → **Account** → **Create API Key**
|
||||
|
||||
```bash
|
||||
export PROWLER_API_TOKEN=$(curl --location 'https://api.prowler.com/api/v1/tokens' \
|
||||
--header 'Content-Type: application/vnd.api+json' \
|
||||
--header 'Accept: application/vnd.api+json' \
|
||||
--data-raw '{
|
||||
"data": {
|
||||
"type": "tokens",
|
||||
"attributes": {
|
||||
"email": "your@email.com",
|
||||
"password": "your-password"
|
||||
}
|
||||
}
|
||||
}' | jq -r .data.attributes.access)
|
||||
export PROWLER_API_KEY="pk_example-api-key"
|
||||
```
|
||||
|
||||
For detailed instructions on creating API keys, see: https://docs.prowler.com/user-guide/providers/prowler-app-api-keys
|
||||
|
||||
|
||||
## AWS Organizations Integration
|
||||
|
||||
For organizations with many AWS accounts, use the included `aws_org_generator.py` script to automatically generate configuration for all accounts in your AWS Organization.
|
||||
|
||||
**📖 Full Guide:** See [AWS Organizations Bulk Provisioning Tutorial](https://docs.prowler.com/user-guide/tutorials/aws-organizations-bulk-provisioning) for complete documentation, examples, and troubleshooting.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using the AWS Organizations generator, deploy the ProwlerRole across all accounts using CloudFormation StackSets:
|
||||
|
||||
**Documentation:** [Deploying Prowler IAM Roles Across AWS Organizations](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/aws/organizations/#deploying-prowler-iam-roles-across-aws-organizations)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Install additional dependencies:
|
||||
```bash
|
||||
pip install -r requirements-aws-org.txt
|
||||
```
|
||||
|
||||
2. Generate YAML configuration for all organization accounts:
|
||||
```bash
|
||||
python aws_org_generator.py -o aws-accounts.yaml --external-id example-external-id
|
||||
```
|
||||
|
||||
3. Run bulk provisioning:
|
||||
```bash
|
||||
python prowler_bulk_provisioning.py aws-accounts.yaml
|
||||
```
|
||||
|
||||
### AWS Organizations Generator Options
|
||||
|
||||
```bash
|
||||
python aws_org_generator.py -o aws-accounts.yaml \
|
||||
--role-name ProwlerRole \
|
||||
--external-id my-external-id \
|
||||
--exclude 123456789012 \
|
||||
--profile org-management
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `-o, --output` | Output YAML file path | `aws-org-accounts.yaml` |
|
||||
| `--role-name` | IAM role name across accounts | `ProwlerRole` |
|
||||
| `--external-id` | External ID for role assumption | None (recommended) |
|
||||
| `--session-name` | Session name for role assumption | None |
|
||||
| `--duration-seconds` | Session duration in seconds | None |
|
||||
| `--alias-format` | Alias template: `{name}`, `{id}`, `{email}` | `{name}` |
|
||||
| `--exclude` | Comma-separated account IDs to exclude | None |
|
||||
| `--include` | Comma-separated account IDs to include | None |
|
||||
| `--profile` | AWS CLI profile name | Default credentials |
|
||||
| `--region` | AWS region | `us-east-1` |
|
||||
| `--dry-run` | Print to stdout without writing | `False` |
|
||||
|
||||
### Examples
|
||||
|
||||
**Generate config for all accounts with custom external ID:**
|
||||
```bash
|
||||
python aws_org_generator.py -o aws-accounts.yaml --external-id prowler-2024-abc123
|
||||
```
|
||||
|
||||
**Exclude management account:**
|
||||
```bash
|
||||
python aws_org_generator.py -o aws-accounts.yaml \
|
||||
--external-id prowler-ext-id \
|
||||
--exclude 123456789012
|
||||
```
|
||||
|
||||
**Use specific AWS profile:**
|
||||
```bash
|
||||
python aws_org_generator.py -o aws-accounts.yaml \
|
||||
--profile org-admin \
|
||||
--external-id prowler-ext-id
|
||||
```
|
||||
|
||||
**Custom alias format:**
|
||||
```bash
|
||||
python aws_org_generator.py -o aws-accounts.yaml \
|
||||
--alias-format "{name}-{id}" \
|
||||
--external-id prowler-ext-id
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
export PROWLER_API_TOKEN="your-prowler-token"
|
||||
export PROWLER_API_KEY="pk_example-api-key"
|
||||
export PROWLER_API_BASE="https://api.prowler.com/api/v1" # Optional, defaults to Prowler Cloud
|
||||
```
|
||||
|
||||
@@ -168,7 +242,7 @@ python prowler_bulk_provisioning.py providers.yaml \
|
||||
|--------|-------------|---------|
|
||||
| `input_file` | YAML file with provider entries | Required |
|
||||
| `--base-url` | API base URL | `https://api.prowler.com/api/v1` |
|
||||
| `--token` | Bearer token | `PROWLER_API_TOKEN` env var |
|
||||
| `--api-key` | Prowler API key | `PROWLER_API_KEY` env var |
|
||||
| `--providers-endpoint` | Providers API endpoint | `/providers` |
|
||||
| `--concurrency` | Number of concurrent requests | `5` |
|
||||
| `--timeout` | Per-request timeout in seconds | `60` |
|
||||
@@ -241,8 +315,8 @@ The Prowler API supports the following authentication methods for GCP:
|
||||
# OR inline:
|
||||
# inline_json:
|
||||
# type: "service_account"
|
||||
# project_id: "your-project"
|
||||
# private_key_id: "key-id"
|
||||
# project_id: "example-project"
|
||||
# private_key_id: "example-key-id"
|
||||
# private_key: "-----BEGIN PRIVATE KEY-----\n..."
|
||||
# client_email: "service-account@project.iam.gserviceaccount.com"
|
||||
# client_id: "1234567890"
|
||||
@@ -379,10 +453,10 @@ python prowler_bulk_provisioning.py providers.yaml --dry-run
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Invalid API Token**
|
||||
1. **Invalid API Key**
|
||||
```
|
||||
Error: 401 Unauthorized
|
||||
Solution: Check your PROWLER_API_TOKEN or --token parameter
|
||||
Solution: Check your PROWLER_API_KEY environment variable or --api-key parameter
|
||||
```
|
||||
|
||||
2. **Network Timeouts**
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
AWS Organizations Account Generator for Prowler Bulk Provisioning
|
||||
|
||||
Generates YAML configuration for all accounts in an AWS Organization,
|
||||
ready to be used with prowler_bulk_provisioning.py.
|
||||
|
||||
Prerequisites:
|
||||
- ProwlerRole (or custom role) must be deployed across all accounts
|
||||
- AWS credentials with Organizations read access (typically management account)
|
||||
- See: https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/aws/organizations/#deploying-prowler-iam-roles-across-aws-organizations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"boto3 is required. Install with: pip install boto3\n"
|
||||
"Or install all dependencies: pip install -r requirements-aws-org.txt"
|
||||
)
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("PyYAML is required. Install with: pip install pyyaml")
|
||||
|
||||
|
||||
def get_org_accounts(
|
||||
profile: Optional[str] = None, region: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve all accounts from AWS Organizations.
|
||||
|
||||
Args:
|
||||
profile: AWS CLI profile name
|
||||
region: AWS region (defaults to us-east-1 for Organizations)
|
||||
|
||||
Returns:
|
||||
List of account dictionaries with id, name, email, and status
|
||||
"""
|
||||
try:
|
||||
session = boto3.Session(profile_name=profile, region_name=region or "us-east-1")
|
||||
client = session.client("organizations")
|
||||
|
||||
accounts = []
|
||||
paginator = client.get_paginator("list_accounts")
|
||||
|
||||
for page in paginator.paginate():
|
||||
for account in page["Accounts"]:
|
||||
# Only include ACTIVE accounts
|
||||
if account["Status"] == "ACTIVE":
|
||||
accounts.append(
|
||||
{
|
||||
"id": account["Id"],
|
||||
"name": account["Name"],
|
||||
"email": account["Email"],
|
||||
"status": account["Status"],
|
||||
}
|
||||
)
|
||||
|
||||
return accounts
|
||||
|
||||
except NoCredentialsError:
|
||||
sys.exit(
|
||||
"No AWS credentials found. Configure credentials using:\n"
|
||||
" - AWS CLI: aws configure\n"
|
||||
" - Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY\n"
|
||||
" - IAM role if running on EC2/ECS/Lambda"
|
||||
)
|
||||
except ClientError as e:
|
||||
error_code = e.response["Error"]["Code"]
|
||||
if error_code == "AccessDeniedException":
|
||||
sys.exit(
|
||||
"Access denied to AWS Organizations API.\n"
|
||||
"Ensure you are using credentials from the management account\n"
|
||||
"with permissions to call organizations:ListAccounts"
|
||||
)
|
||||
elif error_code == "AWSOrganizationsNotInUseException":
|
||||
sys.exit(
|
||||
"AWS Organizations is not enabled for this account.\n"
|
||||
"This script requires an AWS Organization to be set up."
|
||||
)
|
||||
else:
|
||||
sys.exit(f"AWS API error: {e}")
|
||||
except Exception as e:
|
||||
sys.exit(f"Unexpected error listing accounts: {e}")
|
||||
|
||||
|
||||
def generate_yaml_config(
|
||||
accounts: List[Dict[str, Any]],
|
||||
role_name: str = "ProwlerRole",
|
||||
external_id: Optional[str] = None,
|
||||
session_name: Optional[str] = None,
|
||||
duration_seconds: Optional[int] = None,
|
||||
alias_format: str = "{name}",
|
||||
exclude_accounts: Optional[List[str]] = None,
|
||||
include_accounts: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate YAML configuration for Prowler bulk provisioning.
|
||||
|
||||
Args:
|
||||
accounts: List of account dictionaries from get_org_accounts
|
||||
role_name: IAM role name (default: ProwlerRole)
|
||||
external_id: External ID for role assumption (optional but recommended)
|
||||
session_name: Session name for role assumption (optional)
|
||||
duration_seconds: Session duration in seconds (optional)
|
||||
alias_format: Format string for alias (supports {name}, {id}, {email})
|
||||
exclude_accounts: List of account IDs to exclude
|
||||
include_accounts: List of account IDs to include (if set, only these are included)
|
||||
|
||||
Returns:
|
||||
List of provider configurations ready for YAML export
|
||||
"""
|
||||
exclude_accounts = exclude_accounts or []
|
||||
include_accounts = include_accounts or []
|
||||
|
||||
providers = []
|
||||
|
||||
for account in accounts:
|
||||
account_id = account["id"]
|
||||
|
||||
# Apply filters
|
||||
if include_accounts and account_id not in include_accounts:
|
||||
continue
|
||||
if account_id in exclude_accounts:
|
||||
continue
|
||||
|
||||
# Format alias using template
|
||||
alias = alias_format.format(
|
||||
name=account["name"], id=account_id, email=account["email"]
|
||||
)
|
||||
|
||||
# Build role ARN
|
||||
role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"
|
||||
|
||||
# Build credentials section
|
||||
credentials: Dict[str, Any] = {"role_arn": role_arn}
|
||||
|
||||
if external_id:
|
||||
credentials["external_id"] = external_id
|
||||
|
||||
if session_name:
|
||||
credentials["session_name"] = session_name
|
||||
|
||||
if duration_seconds:
|
||||
credentials["duration_seconds"] = duration_seconds
|
||||
|
||||
# Build provider entry
|
||||
provider = {
|
||||
"provider": "aws",
|
||||
"uid": account_id,
|
||||
"alias": alias,
|
||||
"auth_method": "role",
|
||||
"credentials": credentials,
|
||||
}
|
||||
|
||||
providers.append(provider)
|
||||
|
||||
return providers
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to generate AWS Organizations YAML configuration."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Prowler bulk provisioning YAML from AWS Organizations",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Basic usage - generate YAML for all accounts
|
||||
python aws_org_generator.py -o aws-accounts.yaml
|
||||
|
||||
# Use custom role name and external ID
|
||||
python aws_org_generator.py -o aws-accounts.yaml \\
|
||||
--role-name ProwlerExecutionRole \\
|
||||
--external-id my-external-id-12345
|
||||
|
||||
# Use specific AWS profile
|
||||
python aws_org_generator.py -o aws-accounts.yaml \\
|
||||
--profile org-management
|
||||
|
||||
# Exclude specific accounts (e.g., management account)
|
||||
python aws_org_generator.py -o aws-accounts.yaml \\
|
||||
--exclude 123456789012,210987654321
|
||||
|
||||
# Include only specific accounts
|
||||
python aws_org_generator.py -o aws-accounts.yaml \\
|
||||
--include 111111111111,222222222222
|
||||
|
||||
# Custom alias format
|
||||
python aws_org_generator.py -o aws-accounts.yaml \\
|
||||
--alias-format "{name}-{id}"
|
||||
|
||||
Prerequisites:
|
||||
1. Deploy ProwlerRole across all accounts using CloudFormation StackSets:
|
||||
https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/aws/organizations/#deploying-prowler-iam-roles-across-aws-organizations
|
||||
|
||||
2. Ensure AWS credentials have Organizations read access:
|
||||
- organizations:ListAccounts
|
||||
- organizations:DescribeOrganization (optional)
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
default="aws-org-accounts.yaml",
|
||||
help="Output YAML file path (default: aws-org-accounts.yaml)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--role-name",
|
||||
default="ProwlerRole",
|
||||
help="IAM role name deployed across accounts (default: ProwlerRole)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--external-id",
|
||||
help="External ID for role assumption (recommended for security)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--session-name", help="Session name for role assumption (optional)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--duration-seconds",
|
||||
type=int,
|
||||
help="Session duration in seconds (optional, default: 3600)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--alias-format",
|
||||
default="{name}",
|
||||
help="Alias format template. Available: {name}, {id}, {email} (default: {name})",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--exclude",
|
||||
help="Comma-separated list of account IDs to exclude",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--include",
|
||||
help="Comma-separated list of account IDs to include (if set, only these are processed)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
help="AWS CLI profile name (uses default credentials if not specified)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--region",
|
||||
help="AWS region (default: us-east-1, Organizations is global but needs a region)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print configuration to stdout without writing file",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse exclude/include lists
|
||||
exclude_accounts = (
|
||||
[acc.strip() for acc in args.exclude.split(",")] if args.exclude else []
|
||||
)
|
||||
include_accounts = (
|
||||
[acc.strip() for acc in args.include.split(",")] if args.include else []
|
||||
)
|
||||
|
||||
print("Fetching accounts from AWS Organizations...")
|
||||
if args.profile:
|
||||
print(f"Using AWS profile: {args.profile}")
|
||||
|
||||
# Get accounts from Organizations
|
||||
accounts = get_org_accounts(profile=args.profile, region=args.region)
|
||||
|
||||
if not accounts:
|
||||
print("No active accounts found in organization.")
|
||||
return
|
||||
|
||||
print(f"Found {len(accounts)} active accounts in organization")
|
||||
|
||||
# Generate YAML configuration
|
||||
providers = generate_yaml_config(
|
||||
accounts=accounts,
|
||||
role_name=args.role_name,
|
||||
external_id=args.external_id,
|
||||
session_name=args.session_name,
|
||||
duration_seconds=args.duration_seconds,
|
||||
alias_format=args.alias_format,
|
||||
exclude_accounts=exclude_accounts,
|
||||
include_accounts=include_accounts,
|
||||
)
|
||||
|
||||
if not providers:
|
||||
print("No providers generated after applying filters.")
|
||||
return
|
||||
|
||||
print(f"Generated configuration for {len(providers)} accounts")
|
||||
|
||||
# Output YAML
|
||||
yaml_content = yaml.dump(
|
||||
providers, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n--- Generated YAML Configuration ---\n")
|
||||
print(yaml_content)
|
||||
else:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(yaml_content)
|
||||
print(f"\nConfiguration written to: {args.output}")
|
||||
print("\nNext steps:")
|
||||
print(f" 1. Review the generated file: cat {args.output} | head -n 20")
|
||||
print(
|
||||
f" 2. Run bulk provisioning: python prowler_bulk_provisioning.py {args.output}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
# Example AWS Organizations Output
|
||||
#
|
||||
# This is an example of what aws_org_generator.py produces when run against
|
||||
# an AWS Organization. This file can be directly used with prowler_bulk_provisioning.py
|
||||
#
|
||||
# Generated with:
|
||||
# python aws_org_generator.py -o aws-org-accounts.yaml \
|
||||
# --role-name ProwlerRole \
|
||||
# --external-id prowler-ext-id-12345
|
||||
|
||||
- provider: aws
|
||||
uid: '111111111111'
|
||||
alias: Production-Account
|
||||
auth_method: role
|
||||
credentials:
|
||||
role_arn: arn:aws:iam::111111111111:role/ProwlerRole
|
||||
external_id: prowler-ext-id-12345
|
||||
|
||||
- provider: aws
|
||||
uid: '222222222222'
|
||||
alias: Development-Account
|
||||
auth_method: role
|
||||
credentials:
|
||||
role_arn: arn:aws:iam::222222222222:role/ProwlerRole
|
||||
external_id: prowler-ext-id-12345
|
||||
|
||||
- provider: aws
|
||||
uid: '333333333333'
|
||||
alias: Staging-Account
|
||||
auth_method: role
|
||||
credentials:
|
||||
role_arn: arn:aws:iam::333333333333:role/ProwlerRole
|
||||
external_id: prowler-ext-id-12345
|
||||
|
||||
- provider: aws
|
||||
uid: '444444444444'
|
||||
alias: Security-Account
|
||||
auth_method: role
|
||||
credentials:
|
||||
role_arn: arn:aws:iam::444444444444:role/ProwlerRole
|
||||
external_id: prowler-ext-id-12345
|
||||
|
||||
- provider: aws
|
||||
uid: '555555555555'
|
||||
alias: Logging-Account
|
||||
auth_method: role
|
||||
credentials:
|
||||
role_arn: arn:aws:iam::555555555555:role/ProwlerRole
|
||||
external_id: prowler-ext-id-12345
|
||||
@@ -7,7 +7,7 @@ Use with extreme caution. There is no undo.
|
||||
|
||||
Environment:
|
||||
PROWLER_API_BASE (default: https://api.prowler.com/api/v1)
|
||||
PROWLER_API_TOKEN (required unless --token is provided)
|
||||
PROWLER_API_KEY (required unless --api-key is provided)
|
||||
|
||||
Usage:
|
||||
python nuke_providers.py --confirm
|
||||
@@ -39,12 +39,14 @@ import requests
|
||||
# ----------------------------- CLI / Utils --------------------------------- #
|
||||
|
||||
|
||||
def env_or_arg(token_arg: Optional[str]) -> str:
|
||||
"""Get API token from argument or environment variable."""
|
||||
token = token_arg or os.getenv("PROWLER_API_TOKEN")
|
||||
if not token:
|
||||
sys.exit("Missing API token. Set --token or PROWLER_API_TOKEN.")
|
||||
return token
|
||||
def env_or_arg(api_key_arg: Optional[str]) -> str:
|
||||
"""Get API key from argument or environment variable."""
|
||||
api_key = api_key_arg or os.getenv("PROWLER_API_KEY")
|
||||
if not api_key:
|
||||
sys.exit(
|
||||
"Missing API key. Set --api-key or PROWLER_API_KEY environment variable."
|
||||
)
|
||||
return api_key
|
||||
|
||||
|
||||
def normalize_base_url(url: str) -> str:
|
||||
@@ -63,14 +65,14 @@ class ApiClient:
|
||||
"""HTTP client for Prowler API."""
|
||||
|
||||
base_url: str
|
||||
token: str
|
||||
api_key: str
|
||||
verify_ssl: bool = True
|
||||
timeout: int = 60
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
"""Generate HTTP headers for API requests."""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Authorization": f"Api-Key {self.api_key}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
"Accept": "application/vnd.api+json",
|
||||
}
|
||||
@@ -266,7 +268,9 @@ def main():
|
||||
help="API base URL (default: env PROWLER_API_BASE or Prowler Cloud SaaS)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token", default=None, help="Bearer token (default: PROWLER_API_TOKEN)"
|
||||
"--api-key",
|
||||
default=None,
|
||||
help="Prowler API key (default: PROWLER_API_KEY env variable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--filter-provider",
|
||||
@@ -307,12 +311,12 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
token = env_or_arg(args.token)
|
||||
api_key = env_or_arg(args.api_key)
|
||||
base_url = normalize_base_url(args.base_url)
|
||||
|
||||
client = ApiClient(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
api_key=api_key,
|
||||
verify_ssl=not args.insecure,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
@@ -132,12 +132,14 @@ def load_items(path: Path) -> List[Dict[str, Any]]:
|
||||
sys.exit(f"Unsupported input file type: {ext}")
|
||||
|
||||
|
||||
def env_or_arg(token_arg: Optional[str]) -> str:
|
||||
"""Get API token from argument or environment variable."""
|
||||
token = token_arg or os.getenv("PROWLER_API_TOKEN")
|
||||
if not token:
|
||||
sys.exit("Missing API token. Set --token or PROWLER_API_TOKEN.")
|
||||
return token
|
||||
def env_or_arg(api_key_arg: Optional[str]) -> str:
|
||||
"""Get API key from argument or environment variable."""
|
||||
api_key = api_key_arg or os.getenv("PROWLER_API_KEY")
|
||||
if not api_key:
|
||||
sys.exit(
|
||||
"Missing API key. Set --api-key or PROWLER_API_KEY environment variable."
|
||||
)
|
||||
return api_key
|
||||
|
||||
|
||||
def normalize_base_url(url: str) -> str:
|
||||
@@ -395,14 +397,14 @@ class ApiClient:
|
||||
"""HTTP client for Prowler API."""
|
||||
|
||||
base_url: str
|
||||
token: str
|
||||
api_key: str
|
||||
verify_ssl: bool = True
|
||||
timeout: int = 60
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
"""Generate HTTP headers for API requests."""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Authorization": f"Api-Key {self.api_key}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
"Accept": "application/vnd.api+json",
|
||||
}
|
||||
@@ -564,7 +566,9 @@ def main():
|
||||
help="API base URL (default: env PROWLER_API_BASE or Prowler Cloud SaaS).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token", default=None, help="Bearer token (default: PROWLER_API_TOKEN)."
|
||||
"--api-key",
|
||||
default=None,
|
||||
help="Prowler API key (default: PROWLER_API_KEY env variable).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--providers-endpoint",
|
||||
@@ -600,7 +604,7 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
token = env_or_arg(args.token)
|
||||
api_key = env_or_arg(args.api_key)
|
||||
base_url = normalize_base_url(args.base_url)
|
||||
|
||||
items = load_items(Path(args.input_file))
|
||||
@@ -610,7 +614,7 @@ def main():
|
||||
|
||||
client = ApiClient(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
api_key=api_key,
|
||||
verify_ssl=not args.insecure,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# AWS Organizations Generator Dependencies
|
||||
#
|
||||
# This extends the base requirements.txt for the AWS Organizations generator
|
||||
|
||||
# Include base dependencies
|
||||
-r requirements.txt
|
||||
|
||||
# AWS SDK for Python
|
||||
boto3>=1.26.0
|
||||
|
||||
# Note: PyYAML is already included in requirements.txt
|
||||
Reference in New Issue
Block a user