Compare commits

...

12 Commits

Author SHA1 Message Date
Víctor Fernández Poyatos f90b4d22b9 feat: add django command to handle stuck scans 2025-10-23 16:05:16 +02:00
Andoni Alonso e6d1b5639b chore(github): include roadmap in features request template (#9000) 2025-10-23 15:06:34 +02:00
Alan Buscaglia b1856e42f0 chore: update changelog for release v5.13.0 (#8996) 2025-10-23 13:54:30 +02:00
Víctor Fernández Poyatos ba8dbb0d28 fix(s3): file uploading for threatscore (#8993) 2025-10-23 16:07:06 +05:45
Daniel Barranquero b436cc1cac chore(sdk): update changelog to released (#8994) 2025-10-23 15:55:50 +05:45
Josema Camacho 51baa88644 chore(api): Update changelog for API's version 1.14.0 to Prowler 5.13.0 (#8992) 2025-10-23 12:03:07 +02:00
Rubén De la Torre Vico 5098b12e97 chore(mcp): update changelog to released (#8991) 2025-10-23 11:47:58 +02:00
Daniel Barranquero 3d1e7015a6 fix(entra): value errors due tu enums (#8919) 2025-10-23 11:36:51 +02:00
Alejandro Bailo 0b7f02f7e4 feat: Check Findings component (#8976)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2025-10-23 10:38:25 +02:00
Daniel Barranquero c0396e97bf feat(docs): add new provider e2e guide (#8430)
Co-authored-by: HugoPBrito <hugopbrit@gmail.com>
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-23 10:09:15 +02:00
Andoni Alonso 8d4fa46038 chore: script to generate AWS accounts list from AWS Org for bulk provisioning (#8903) 2025-10-22 16:23:14 -04:00
Daniel Barranquero 4b160257b9 chore(sdk): update changelog for v5.13.0 (#8989) 2025-10-22 12:26:58 -04:00
49 changed files with 5849 additions and 431 deletions
+1 -1
View File
@@ -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
View File
@@ -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)],
),
),
+49
View File
@@ -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,
+13 -1
View File
@@ -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(
{
+22 -27
View File
@@ -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)}")
+6 -1
View File
@@ -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)
+19 -1
View File
@@ -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):
+26 -8
View File
@@ -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,
+27
View File
@@ -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
File diff suppressed because it is too large Load Diff
+300 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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)
+3 -7
View File
@@ -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)
@@ -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,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
View File
@@ -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>
);
};
+87
View File
@@ -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,
}}
/>
);
};
+41 -45
View File
@@ -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>
</>
);
}
+15 -45
View File
@@ -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>
-53
View File
@@ -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,
},
});
+10 -5
View File
@@ -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}
/>
);
@@ -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: {
@@ -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 };
+8 -21
View File
@@ -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";
+2 -1
View File
@@ -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,
)}
>
+1 -1
View File
@@ -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}
+95 -21
View File
@@ -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
View File
@@ -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