Compare commits

...
Author SHA1 Message Date
Andoni A. ad3b57d405 feat(image): add image provider API support with registry scan mode
- Add IMAGE provider type to API models, serializers, views, and utils
- Add registry credential handling (username/password, token, filters)
- Add UID validation for registry URLs with port validation
- Add connection testing via OCI registry adapter
- Add registry scan mode with OCI, Docker Hub, and ECR adapter layer
- Add per-image progress tracking and Trivy native auth
- Skip compliance/reports for IMAGE provider scans
- Add migration, OpenAPI spec updates, and comprehensive tests
2026-02-18 18:21:20 +01:00
Andoni A. f7adbc2dcf feat(image): add docker login and pull for private registry authentication
Trivy's remote source cannot authenticate against Docker Hub (and some
other registries) even after docker login. This adds a docker login +
docker pull flow before scanning so Trivy can access private images
from the local Docker daemon.

- Add _docker_login, _docker_pull, _docker_logout, cleanup methods
- Add _extract_registry to determine registry from image reference
- Wrap run() in try/finally to ensure cleanup on success or error
- Wire registry credentials from CLI args to ImageProvider
- Add ImageDockerLoginError and ImageDockerNotFoundError exceptions
2026-02-06 15:08:42 +01:00
Andoni A. 41e972e088 chore(image): remove POC mention from CHANGELOG and drop provider README 2026-02-06 14:04:18 +01:00
Andoni A. 31d9a23225 feat(image): add container image provider for CLI scanning
Add a new Image provider that uses Trivy for container image vulnerability
and secret scanning, integrated into the Prowler CLI.

- ImageProvider class with Trivy integration for vuln/secret/misconfig scanning
- CLI support via `prowler image -I <image>` with severity filters, timeout,
  ignore-unfixed, and image-list-file options
- CheckReportImage model for image-specific findings
- Custom exceptions (9000-9005) with clear remediation messages
- Error handling for Trivy failures (non-zero exit, binary not found)
- Batch processing of findings with progress bar
- test_connection() for registry accessibility checks
- Comprehensive test coverage
2026-02-06 13:59:48 +01:00
Josema Camacho 4ebded6ab1 chore(attack-paths): A Neo4j database per tenant (#9955) 2026-02-05 10:29:37 +01:00
Alan Buscaglia 770269772a test(ui): stabilize auth and provider e2e flows (#9945) 2026-02-05 09:56:49 +01:00
Josema Camacho ab18ddb81a chore(api): prepare changelog for 5.18.0 release (#9960) 2026-02-05 09:34:54 +01:00
Pedro Martín cda7f89091 feat(azure): add HIPAA compliance framework (#9957) 2026-02-05 08:45:52 +01:00
Josema CamachoandCésar Arroba 658ae755ae chore(attack-paths): pin cartography to 0.126.1 (#9893)
Co-authored-by: César Arroba <cesar@prowler.com>
2026-02-04 19:20:15 +01:00
Daniel Barranquero 486719737b chore(sdk): prepare changelog for v5.18.0 (#9958) 2026-02-04 19:16:19 +01:00
Hugo Pereira BritoandJosema Camacho cb9ab03778 feat(aws): revert Adding check that AWS Auto Scaling group has deletion protection (#9956)
Co-authored-by: Josema Camacho <hello@josema.xyz>
2026-02-04 16:53:08 +01:00
Rubén De la Torre VicoandDaniel Barranquero 96a2262730 chore(azure): enhance metadata for storage service (#9628)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-02-04 16:40:47 +01:00
69818abdd0 feat(aws): Adding check that AWS Auto Scaling group has deletion protection (#9928)
Co-authored-by: Serhii Sokolov <serhii.sokolov@automat-it.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: HugoPBrito <hugopbrit@gmail.com>
2026-02-04 13:17:13 +01:00
Rubén De la Torre VicoandDaniel Barranquero d447bdfe54 chore(azure): enhance metadata for network service (#9624)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-02-04 11:56:25 +01:00
Rubén De la Torre VicoandDaniel Barranquero b5095f5dc7 chore(azure): enhance metadata for sqlserver service (#9627)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-02-04 08:03:20 +01:00
Pawan GambhirandDaniel Barranquero 9fe71d1046 fix(dashboard): resolve CSV/XLSX download failure with filters (#9946)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-02-03 18:47:42 +01:00
Hugo Pereira Brito 547c53e07c ci: add duplicate test name checker across providers (#9949) 2026-02-03 12:00:41 +01:00
Víctor Fernández Poyatos e1900fc776 fix(api): bump outdated versions (#9950) 2026-02-03 11:03:11 +01:00
Víctor Fernández Poyatos 3c0cb3cd58 chore: update poetry lock for SDK and API (#9941) 2026-02-03 09:44:02 +01:00
Daniel Barranquero e66c9864f5 fix: modify tests files name (#9942) 2026-02-03 08:05:27 +01:00
Hugo Pereira Brito b1f9971617 feat(api): add Cloudflare provider support (#9907) 2026-02-02 14:08:33 +01:00
Alex Baker d01f399cb2 docs(SECURITY.md): Update Link to Security (#9927) 2026-02-02 13:27:12 +01:00
Hugo Pereira Brito 2535b55951 fix(jira): truncate summary to 255 characters to prevent INVALID_INPUT error (#9926) 2026-02-02 12:11:03 +01:00
Rubén De la Torre VicoandDaniel Barranquero 0f55d6e21d chore(azure): enhance metadata for postgresql service (#9626)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-01-30 14:09:11 +01:00
Alan Buscaglia afb666e0da feat(ci): add test impact analysis for selective test execution (#9844) 2026-01-29 17:51:25 +01:00
Andoni Alonso 13cd882ed2 docs(developer-guide): add AI Skills reference to introduction (#9924) 2026-01-29 16:55:15 +01:00
Daniel BarranqueroandAndoni A. f65879346b feat(docs): add openstack cli first version (#9848)
Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
2026-01-29 14:24:44 +01:00
Alejandro Bailo 013f2e5d32 fix(ui): resource drawer duplicates and performance optimization (#9921) 2026-01-29 14:15:05 +01:00
RosaRivas bcaa95f973 docs: replace membership by organization as it appears in prowler app (#9918) 2026-01-29 13:59:48 +01:00
Andoni Alonso 625dd37fd4 fix(docs): standardize authentication page titles across providers (#9920) 2026-01-29 13:56:03 +01:00
Alejandro BailoandClaude Opus 4.5 fee2f84b89 fix(ui): patch React Server Components DoS vulnerability (GHSA-83fc-fqcc-2hmg) (#9917)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 13:37:19 +01:00
Daniel Barranquero 08730b4eb5 feat(openstack): add Openstack provider (#9811) 2026-01-29 12:54:18 +01:00
Hugo Pereira Brito c183a2a89a fix(azure): remove duplicated findings in entra_user_with_vm_access_has_mfa (#9914) 2026-01-29 12:20:15 +01:00
mohd4adilandDaniel Barranquero e97e31c7ca chore(aws): add support for trusted aws accounts in cross account checks for s3, eventbridge bus, eventbridge schema and dynamodb (#9692)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-01-29 09:13:34 +01:00
Rubén De la Torre VicoandDaniel Barranquero ad7be95dc3 chore(azure): enhance metadata for defender service (#9618)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-01-28 17:41:19 +01:00
Kay AgahdandDaniel Barranquero 04e2d15dd2 feat(aws): add check rds_instance_extended_support (#9865)
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
2026-01-28 16:49:35 +01:00
Hugo Pereira Brito 143d4b7c29 fix(docs): azure auth permissions and broken image (#9906) 2026-01-28 14:55:16 +01:00
Alejandro Bailo 0c5778d4a1 feat: resource view re-styling with new components (#9864) 2026-01-28 14:07:01 +01:00
Víctor Fernández Poyatos c77d9dd3a9 fix(api): enable autocommit for concurrent index migrations (#9905) 2026-01-28 13:26:16 +01:00
Víctor Fernández Poyatos 8783e963d3 feat(api): remove unused database indexes and improve new failed findings index (#9904) 2026-01-28 12:35:36 +01:00
Rubén De la Torre VicoandDaniel Barranquero 5407f3c68e chore(azure): enhance metadata for mysql service (#9623)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-01-28 11:05:01 +01:00
Alejandro Bailo 83ec3fa458 chore(ui): update CHANGELOG.md (#9901) 2026-01-28 09:21:24 +01:00
dependabot[bot] ac32f03de3 build(deps): bump azure-core from 1.35.0 to 1.38.0 in /api (#9790)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 17:17:33 +01:00
dependabot[bot] 7b11a716b9 build(deps): bump azure-core from 1.35.0 to 1.38.0 (#9791)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 17:11:10 +01:00
Pepe Fagoaga b2c18b69ee fix(api): handle AccessDenied during AssumeRole in events endpoint (#9899) 2026-01-27 15:32:51 +01:00
Andoni Alonso 727fafb147 fix(attack-paths): correct aws-security-groups-open-internet-facing query (#9892) 2026-01-27 14:20:05 +01:00
Hugo Pereira BritoandAndoni Alonso 80c94faff9 feat(cloudflare): --account-id filter support (#9894)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
2026-01-27 14:18:55 +01:00
Alejandro Bailo 065827cd38 feat: upgrade to Next.js 16.1.3 (#9826) 2026-01-27 14:02:31 +01:00
369 changed files with 26201 additions and 9564 deletions

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST=apoc.*
NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED=apoc.*
NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
# Neo4j Prowler settings
ATTACK_PATHS_FINDINGS_BATCH_SIZE=1000
ATTACK_PATHS_BATCH_SIZE=1000
# Celery-Prowler task settings
TASK_RETRY_DELAY_SECONDS=0.1
+7
View File
@@ -57,6 +57,11 @@ provider/cloudflare:
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
- any-glob-to-any-file: "tests/providers/cloudflare/**"
provider/openstack:
- changed-files:
- any-glob-to-any-file: "prowler/providers/openstack/**"
- any-glob-to-any-file: "tests/providers/openstack/**"
github_actions:
- changed-files:
- any-glob-to-any-file: ".github/workflows/*"
@@ -77,6 +82,7 @@ mutelist:
- any-glob-to-any-file: "prowler/providers/oraclecloud/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/alibabacloud/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/cloudflare/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/openstack/lib/mutelist/**"
- any-glob-to-any-file: "tests/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/aws/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/azure/lib/mutelist/**"
@@ -87,6 +93,7 @@ mutelist:
- any-glob-to-any-file: "tests/providers/oraclecloud/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/alibabacloud/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/cloudflare/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**"
integration/s3:
- changed-files:
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
Test Impact Analysis Script
Analyzes changed files and determines which tests need to run.
Outputs GitHub Actions compatible outputs.
Usage:
python test-impact.py <changed_files...>
python test-impact.py --from-stdin # Read files from stdin (one per line)
Outputs (for GitHub Actions):
- run-all: "true" if critical paths changed
- sdk-tests: Space-separated list of SDK test paths
- api-tests: Space-separated list of API test paths
- ui-e2e: Space-separated list of UI E2E test paths
- modules: Comma-separated list of affected module names
"""
import fnmatch
import os
import sys
from pathlib import Path
import yaml
def load_config() -> dict:
"""Load test-impact.yml configuration."""
config_path = Path(__file__).parent.parent / "test-impact.yml"
with open(config_path) as f:
return yaml.safe_load(f)
def matches_pattern(file_path: str, pattern: str) -> bool:
"""Check if file path matches a glob pattern."""
# Normalize paths
file_path = file_path.strip("/")
pattern = pattern.strip("/")
# Handle ** patterns
if "**" in pattern:
# Convert glob pattern to work with fnmatch
# e.g., "prowler/lib/**" matches "prowler/lib/check/foo.py"
base = pattern.replace("/**", "")
if file_path.startswith(base):
return True
# Also try standard fnmatch
return fnmatch.fnmatch(file_path, pattern)
return fnmatch.fnmatch(file_path, pattern)
def filter_ignored_files(
changed_files: list[str], ignored_paths: list[str]
) -> list[str]:
"""Filter out files that match ignored patterns."""
filtered = []
for file_path in changed_files:
is_ignored = False
for pattern in ignored_paths:
if matches_pattern(file_path, pattern):
print(f" [IGNORED] {file_path} matches {pattern}", file=sys.stderr)
is_ignored = True
break
if not is_ignored:
filtered.append(file_path)
return filtered
def check_critical_paths(changed_files: list[str], critical_paths: list[str]) -> bool:
"""Check if any changed file matches critical paths."""
for file_path in changed_files:
for pattern in critical_paths:
if matches_pattern(file_path, pattern):
print(f" [CRITICAL] {file_path} matches {pattern}", file=sys.stderr)
return True
return False
def find_affected_modules(
changed_files: list[str], modules: list[dict]
) -> dict[str, dict]:
"""Find which modules are affected by changed files."""
affected = {}
for file_path in changed_files:
for module in modules:
module_name = module["name"]
match_patterns = module.get("match", [])
for pattern in match_patterns:
if matches_pattern(file_path, pattern):
if module_name not in affected:
affected[module_name] = {
"tests": set(),
"e2e": set(),
"matched_files": [],
}
affected[module_name]["matched_files"].append(file_path)
# Add test patterns
for test_pattern in module.get("tests", []):
affected[module_name]["tests"].add(test_pattern)
# Add E2E patterns
for e2e_pattern in module.get("e2e", []):
affected[module_name]["e2e"].add(e2e_pattern)
break # File matched this module, move to next file
return affected
def categorize_tests(
affected_modules: dict[str, dict],
) -> tuple[set[str], set[str], set[str]]:
"""Categorize tests into SDK, API, and UI E2E."""
sdk_tests = set()
api_tests = set()
ui_e2e = set()
for module_name, data in affected_modules.items():
for test_path in data["tests"]:
if test_path.startswith("tests/"):
sdk_tests.add(test_path)
elif test_path.startswith("api/"):
api_tests.add(test_path)
for e2e_path in data["e2e"]:
ui_e2e.add(e2e_path)
return sdk_tests, api_tests, ui_e2e
def set_github_output(name: str, value: str):
"""Set GitHub Actions output."""
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as f:
# Handle multiline values
if "\n" in value:
import uuid
delimiter = uuid.uuid4().hex
f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{name}={value}\n")
# Print for debugging (without deprecated format)
print(f" {name}={value}", file=sys.stderr)
def main():
# Parse arguments
if "--from-stdin" in sys.argv:
changed_files = [line.strip() for line in sys.stdin if line.strip()]
else:
changed_files = [f for f in sys.argv[1:] if f and not f.startswith("-")]
if not changed_files:
print("No changed files provided", file=sys.stderr)
set_github_output("run-all", "false")
set_github_output("sdk-tests", "")
set_github_output("api-tests", "")
set_github_output("ui-e2e", "")
set_github_output("modules", "")
set_github_output("has-tests", "false")
return
print(f"Analyzing {len(changed_files)} changed files...", file=sys.stderr)
for f in changed_files[:10]: # Show first 10
print(f" - {f}", file=sys.stderr)
if len(changed_files) > 10:
print(f" ... and {len(changed_files) - 10} more", file=sys.stderr)
# Load configuration
config = load_config()
# Filter out ignored files (docs, configs, etc.)
ignored_paths = config.get("ignored", {}).get("paths", [])
changed_files = filter_ignored_files(changed_files, ignored_paths)
if not changed_files:
print("\nAll changed files are ignored (docs, configs, etc.)", file=sys.stderr)
print("No tests needed.", file=sys.stderr)
set_github_output("run-all", "false")
set_github_output("sdk-tests", "")
set_github_output("api-tests", "")
set_github_output("ui-e2e", "")
set_github_output("modules", "none-ignored")
set_github_output("has-tests", "false")
return
print(
f"\n{len(changed_files)} files remain after filtering ignored paths",
file=sys.stderr,
)
# Check critical paths
critical_paths = config.get("critical", {}).get("paths", [])
if check_critical_paths(changed_files, critical_paths):
print("\nCritical path changed - running ALL tests", file=sys.stderr)
set_github_output("run-all", "true")
set_github_output("sdk-tests", "tests/")
set_github_output("api-tests", "api/src/backend/")
set_github_output("ui-e2e", "ui/tests/")
set_github_output("modules", "all")
set_github_output("has-tests", "true")
return
# Find affected modules
modules = config.get("modules", [])
affected = find_affected_modules(changed_files, modules)
if not affected:
print("\nNo test-mapped modules affected", file=sys.stderr)
set_github_output("run-all", "false")
set_github_output("sdk-tests", "")
set_github_output("api-tests", "")
set_github_output("ui-e2e", "")
set_github_output("modules", "")
set_github_output("has-tests", "false")
return
# Report affected modules
print(f"\nAffected modules: {len(affected)}", file=sys.stderr)
for module_name, data in affected.items():
print(f" [{module_name}]", file=sys.stderr)
for f in data["matched_files"][:3]:
print(f" - {f}", file=sys.stderr)
if len(data["matched_files"]) > 3:
print(
f" ... and {len(data['matched_files']) - 3} more files",
file=sys.stderr,
)
# Categorize tests
sdk_tests, api_tests, ui_e2e = categorize_tests(affected)
# Output results
print("\nTest paths to run:", file=sys.stderr)
print(f" SDK: {sdk_tests or 'none'}", file=sys.stderr)
print(f" API: {api_tests or 'none'}", file=sys.stderr)
print(f" E2E: {ui_e2e or 'none'}", file=sys.stderr)
set_github_output("run-all", "false")
set_github_output("sdk-tests", " ".join(sorted(sdk_tests)))
set_github_output("api-tests", " ".join(sorted(api_tests)))
set_github_output("ui-e2e", " ".join(sorted(ui_e2e)))
set_github_output("modules", ",".join(sorted(affected.keys())))
set_github_output(
"has-tests", "true" if (sdk_tests or api_tests or ui_e2e) else "false"
)
if __name__ == "__main__":
main()
+399
View File
@@ -0,0 +1,399 @@
# Test Impact Analysis Configuration
# Defines which tests to run based on changed files
#
# Usage: Changes to paths in 'critical' always run all tests.
# Changes to paths in 'modules' run only the mapped tests.
# Changes to paths in 'ignored' don't trigger any tests.
# Ignored paths - changes here don't trigger any tests
# Documentation, configs, and other non-code files
ignored:
paths:
# Documentation
- docs/**
- "*.md"
- "**/*.md"
- mkdocs.yml
# Config files that don't affect runtime
- .gitignore
- .gitattributes
- .editorconfig
- .pre-commit-config.yaml
- .backportrc.json
- CODEOWNERS
- LICENSE
# IDE/Editor configs
- .vscode/**
- .idea/**
# Examples and contrib (not production code)
- examples/**
- contrib/**
# Skills (AI agent configs, not runtime)
- skills/**
# Permissions docs
- permissions/**
# Critical paths - changes here run ALL tests
# These are foundational/shared code that can affect anything
critical:
paths:
# SDK Core
- prowler/lib/**
- prowler/config/**
- prowler/exceptions/**
- prowler/providers/common/**
# API Core
- api/src/backend/api/models.py
- api/src/backend/config/**
- api/src/backend/conftest.py
# UI Core
- ui/lib/**
- ui/types/**
- ui/config/**
- ui/middleware.ts
# CI/CD changes
- .github/workflows/**
- .github/test-impact.yml
# Module mappings - path patterns to test patterns
modules:
# ============================================
# SDK - Providers (each provider is isolated)
# ============================================
- name: sdk-aws
match:
- prowler/providers/aws/**
- prowler/compliance/aws/**
tests:
- tests/providers/aws/**
e2e: []
- name: sdk-azure
match:
- prowler/providers/azure/**
- prowler/compliance/azure/**
tests:
- tests/providers/azure/**
e2e: []
- name: sdk-gcp
match:
- prowler/providers/gcp/**
- prowler/compliance/gcp/**
tests:
- tests/providers/gcp/**
e2e: []
- name: sdk-kubernetes
match:
- prowler/providers/kubernetes/**
- prowler/compliance/kubernetes/**
tests:
- tests/providers/kubernetes/**
e2e: []
- name: sdk-github
match:
- prowler/providers/github/**
- prowler/compliance/github/**
tests:
- tests/providers/github/**
e2e: []
- name: sdk-m365
match:
- prowler/providers/m365/**
- prowler/compliance/m365/**
tests:
- tests/providers/m365/**
e2e: []
- name: sdk-alibabacloud
match:
- prowler/providers/alibabacloud/**
- prowler/compliance/alibabacloud/**
tests:
- tests/providers/alibabacloud/**
e2e: []
- name: sdk-cloudflare
match:
- prowler/providers/cloudflare/**
- prowler/compliance/cloudflare/**
tests:
- tests/providers/cloudflare/**
e2e: []
- name: sdk-oraclecloud
match:
- prowler/providers/oraclecloud/**
- prowler/compliance/oraclecloud/**
tests:
- tests/providers/oraclecloud/**
e2e: []
- name: sdk-mongodbatlas
match:
- prowler/providers/mongodbatlas/**
- prowler/compliance/mongodbatlas/**
tests:
- tests/providers/mongodbatlas/**
e2e: []
- name: sdk-nhn
match:
- prowler/providers/nhn/**
- prowler/compliance/nhn/**
tests:
- tests/providers/nhn/**
e2e: []
- name: sdk-iac
match:
- prowler/providers/iac/**
- prowler/compliance/iac/**
tests:
- tests/providers/iac/**
e2e: []
- name: sdk-llm
match:
- prowler/providers/llm/**
- prowler/compliance/llm/**
tests:
- tests/providers/llm/**
e2e: []
# ============================================
# SDK - Lib modules
# ============================================
- name: sdk-lib-check
match:
- prowler/lib/check/**
tests:
- tests/lib/check/**
e2e: []
- name: sdk-lib-outputs
match:
- prowler/lib/outputs/**
tests:
- tests/lib/outputs/**
e2e: []
- name: sdk-lib-scan
match:
- prowler/lib/scan/**
tests:
- tests/lib/scan/**
e2e: []
- name: sdk-lib-cli
match:
- prowler/lib/cli/**
tests:
- tests/lib/cli/**
e2e: []
- name: sdk-lib-mutelist
match:
- prowler/lib/mutelist/**
tests:
- tests/lib/mutelist/**
e2e: []
# ============================================
# API - Views, Serializers, Tasks
# ============================================
- name: api-views
match:
- api/src/backend/api/v1/views.py
tests:
- api/src/backend/api/tests/test_views.py
e2e:
# API view changes can break UI
- ui/tests/**
- name: api-serializers
match:
- api/src/backend/api/v1/serializers.py
- api/src/backend/api/v1/serializer_utils/**
tests:
- api/src/backend/api/tests/**
e2e:
# Serializer changes affect API responses → UI
- ui/tests/**
- name: api-filters
match:
- api/src/backend/api/filters.py
tests:
- api/src/backend/api/tests/**
e2e: []
- name: api-rbac
match:
- api/src/backend/api/rbac/**
tests:
- api/src/backend/api/tests/**
e2e:
- ui/tests/roles/**
- name: api-tasks
match:
- api/src/backend/tasks/**
tests:
- api/src/backend/tasks/tests/**
e2e: []
- name: api-attack-paths
match:
- api/src/backend/api/attack_paths/**
tests:
- api/src/backend/api/tests/test_attack_paths.py
e2e: []
# ============================================
# UI - Components and Features
# ============================================
- name: ui-providers
match:
- ui/components/providers/**
- ui/actions/providers/**
- ui/app/**/providers/**
tests: []
e2e:
- ui/tests/providers/**
- name: ui-findings
match:
- ui/components/findings/**
- ui/actions/findings/**
- ui/app/**/findings/**
tests: []
e2e:
- ui/tests/findings/**
- name: ui-scans
match:
- ui/components/scans/**
- ui/actions/scans/**
- ui/app/**/scans/**
tests: []
e2e:
- ui/tests/scans/**
- name: ui-compliance
match:
- ui/components/compliance/**
- ui/actions/compliances/**
- ui/app/**/compliance/**
tests: []
e2e:
- ui/tests/compliance/**
- name: ui-auth
match:
- ui/components/auth/**
- ui/actions/auth/**
- ui/app/(auth)/**
tests: []
e2e:
- ui/tests/sign-in/**
- ui/tests/sign-up/**
- name: ui-invitations
match:
- ui/components/invitations/**
- ui/actions/invitations/**
- ui/app/**/invitations/**
tests: []
e2e:
- ui/tests/invitations/**
- name: ui-roles
match:
- ui/components/roles/**
- ui/actions/roles/**
- ui/app/**/roles/**
tests: []
e2e:
- ui/tests/roles/**
- name: ui-users
match:
- ui/components/users/**
- ui/actions/users/**
- ui/app/**/users/**
tests: []
e2e:
- ui/tests/users/**
- name: ui-integrations
match:
- ui/components/integrations/**
- ui/actions/integrations/**
- ui/app/**/integrations/**
tests: []
e2e:
- ui/tests/integrations/**
- name: ui-resources
match:
- ui/components/resources/**
- ui/actions/resources/**
- ui/app/**/resources/**
tests: []
e2e:
- ui/tests/resources/**
- name: ui-profile
match:
- ui/app/**/profile/**
tests: []
e2e:
- ui/tests/profile/**
- name: ui-lighthouse
match:
- ui/components/lighthouse/**
- ui/actions/lighthouse/**
- ui/app/**/lighthouse/**
- ui/lib/lighthouse/**
tests: []
e2e:
- ui/tests/lighthouse/**
- name: ui-overview
match:
- ui/components/overview/**
- ui/actions/overview/**
tests: []
e2e:
- ui/tests/home/**
- name: ui-shadcn
match:
- ui/components/shadcn/**
- ui/components/ui/**
tests: []
e2e:
# Shared components can affect any E2E
- ui/tests/**
- name: ui-attack-paths
match:
- ui/components/attack-paths/**
- ui/actions/attack-paths/**
- ui/app/**/attack-paths/**
tests: []
e2e:
- ui/tests/attack-paths/**
@@ -0,0 +1,91 @@
name: 'SDK: Check Duplicate Test Names'
on:
pull_request:
branches:
- 'master'
- 'v5.*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-duplicate-test-names:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Check for duplicate test names across providers
run: |
python3 << 'EOF'
import sys
from collections import defaultdict
from pathlib import Path
def find_duplicate_test_names():
"""Find test files with the same name across different providers."""
tests_dir = Path("tests/providers")
if not tests_dir.exists():
print("tests/providers directory not found")
sys.exit(0)
# Dictionary: filename -> list of (provider, full_path)
test_files = defaultdict(list)
# Find all *_test.py files
for test_file in tests_dir.rglob("*_test.py"):
relative_path = test_file.relative_to(tests_dir)
provider = relative_path.parts[0]
filename = test_file.name
test_files[filename].append((provider, str(test_file)))
# Find duplicates (files appearing in multiple providers)
duplicates = {
filename: locations
for filename, locations in test_files.items()
if len(set(loc[0] for loc in locations)) > 1
}
if not duplicates:
print("No duplicate test file names found across providers.")
print("All test names are unique within the repository.")
sys.exit(0)
# Report duplicates
print("::error::Duplicate test file names found across providers!")
print()
print("=" * 70)
print("DUPLICATE TEST NAMES DETECTED")
print("=" * 70)
print()
print("The following test files have the same name in multiple providers.")
print("Please rename YOUR new test file by adding the provider prefix.")
print()
print("Example: 'kms_service_test.py' -> 'oraclecloud_kms_service_test.py'")
print()
for filename, locations in sorted(duplicates.items()):
print(f"### {filename}")
print(f" Found in {len(locations)} providers:")
for provider, path in sorted(locations):
print(f" - {provider}: {path}")
print()
print(f" Suggested fix: Rename your new file to '<provider>_{filename}'")
print()
print("=" * 70)
print()
print("See: tests/providers/TESTING.md for naming conventions.")
sys.exit(1)
if __name__ == "__main__":
find_duplicate_test_names()
EOF
+24
View File
@@ -414,6 +414,30 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-oraclecloud
files: ./oraclecloud_coverage.xml
# OpenStack Provider
- name: Check if OpenStack files changed
if: steps.check-changes.outputs.any_changed == 'true'
id: changed-openstack
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
with:
files: |
./prowler/**/openstack/**
./tests/**/openstack/**
./poetry.lock
- name: Run OpenStack tests
if: steps.changed-openstack.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/openstack --cov-report=xml:openstack_coverage.xml tests/providers/openstack
- name: Upload OpenStack coverage to Codecov
if: steps.changed-openstack.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: prowler-py${{ matrix.python-version }}-openstack
files: ./openstack_coverage.xml
# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
+112
View File
@@ -0,0 +1,112 @@
name: Test Impact Analysis
on:
workflow_call:
outputs:
run-all:
description: "Whether to run all tests (critical path changed)"
value: ${{ jobs.analyze.outputs.run-all }}
sdk-tests:
description: "SDK test paths to run"
value: ${{ jobs.analyze.outputs.sdk-tests }}
api-tests:
description: "API test paths to run"
value: ${{ jobs.analyze.outputs.api-tests }}
ui-e2e:
description: "UI E2E test paths to run"
value: ${{ jobs.analyze.outputs.ui-e2e }}
modules:
description: "Comma-separated list of affected modules"
value: ${{ jobs.analyze.outputs.modules }}
has-tests:
description: "Whether there are any tests to run"
value: ${{ jobs.analyze.outputs.has-tests }}
has-sdk-tests:
description: "Whether there are SDK tests to run"
value: ${{ jobs.analyze.outputs.has-sdk-tests }}
has-api-tests:
description: "Whether there are API tests to run"
value: ${{ jobs.analyze.outputs.has-api-tests }}
has-ui-e2e:
description: "Whether there are UI E2E tests to run"
value: ${{ jobs.analyze.outputs.has-ui-e2e }}
jobs:
analyze:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
run-all: ${{ steps.impact.outputs.run-all }}
sdk-tests: ${{ steps.impact.outputs.sdk-tests }}
api-tests: ${{ steps.impact.outputs.api-tests }}
ui-e2e: ${{ steps.impact.outputs.ui-e2e }}
modules: ${{ steps.impact.outputs.modules }}
has-tests: ${{ steps.impact.outputs.has-tests }}
has-sdk-tests: ${{ steps.set-flags.outputs.has-sdk-tests }}
has-api-tests: ${{ steps.set-flags.outputs.has-api-tests }}
has-ui-e2e: ${{ steps.set-flags.outputs.has-ui-e2e }}
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
- name: Setup Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: '3.12'
- name: Install PyYAML
run: pip install pyyaml
- name: Analyze test impact
id: impact
run: |
echo "Changed files:"
echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n'
echo ""
python .github/scripts/test-impact.py ${{ steps.changed-files.outputs.all_changed_files }}
- name: Set convenience flags
id: set-flags
run: |
if [[ -n "${{ steps.impact.outputs.sdk-tests }}" ]]; then
echo "has-sdk-tests=true" >> $GITHUB_OUTPUT
else
echo "has-sdk-tests=false" >> $GITHUB_OUTPUT
fi
if [[ -n "${{ steps.impact.outputs.api-tests }}" ]]; then
echo "has-api-tests=true" >> $GITHUB_OUTPUT
else
echo "has-api-tests=false" >> $GITHUB_OUTPUT
fi
if [[ -n "${{ steps.impact.outputs.ui-e2e }}" ]]; then
echo "has-ui-e2e=true" >> $GITHUB_OUTPUT
else
echo "has-ui-e2e=false" >> $GITHUB_OUTPUT
fi
- name: Summary
run: |
echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ steps.impact.outputs.run-all }}" == "true" ]]; then
echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY
else
echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY
echo "\`${{ steps.impact.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY
echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| SDK Tests | \`${{ steps.impact.outputs.sdk-tests || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| API Tests | \`${{ steps.impact.outputs.api-tests || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| UI E2E | \`${{ steps.impact.outputs.ui-e2e || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY
fi
+238
View File
@@ -0,0 +1,238 @@
name: UI - E2E Tests (Optimized)
# This is an optimized version that runs only relevant E2E tests
# based on changed files. Falls back to running all tests if
# critical paths are changed or if impact analysis fails.
on:
pull_request:
branches:
- master
- "v5.*"
paths:
- '.github/workflows/ui-e2e-tests-v2.yml'
- '.github/test-impact.yml'
- 'ui/**'
- 'api/**' # API changes can affect UI E2E
jobs:
# First, analyze which tests need to run
impact-analysis:
if: github.repository == 'prowler-cloud/prowler'
uses: ./.github/workflows/test-impact-analysis.yml
# Run E2E tests based on impact analysis
e2e-tests:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
env:
AUTH_SECRET: 'fallback-ci-secret-for-testing'
AUTH_TRUST_HOST: true
NEXTAUTH_URL: 'http://localhost:3000'
NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1'
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
E2E_AWS_PROVIDER_ACCESS_KEY: ${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}
E2E_AWS_PROVIDER_SECRET_KEY: ${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}
E2E_AWS_PROVIDER_ROLE_ARN: ${{ secrets.E2E_AWS_PROVIDER_ROLE_ARN }}
E2E_AZURE_SUBSCRIPTION_ID: ${{ secrets.E2E_AZURE_SUBSCRIPTION_ID }}
E2E_AZURE_CLIENT_ID: ${{ secrets.E2E_AZURE_CLIENT_ID }}
E2E_AZURE_SECRET_ID: ${{ secrets.E2E_AZURE_SECRET_ID }}
E2E_AZURE_TENANT_ID: ${{ secrets.E2E_AZURE_TENANT_ID }}
E2E_M365_DOMAIN_ID: ${{ secrets.E2E_M365_DOMAIN_ID }}
E2E_M365_CLIENT_ID: ${{ secrets.E2E_M365_CLIENT_ID }}
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
E2E_KUBERNETES_CONTEXT: 'kind-kind'
E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }}
E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }}
E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }}
E2E_GITHUB_BASE64_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_BASE64_APP_PRIVATE_KEY }}
E2E_GITHUB_USERNAME: ${{ secrets.E2E_GITHUB_USERNAME }}
E2E_GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_PERSONAL_ACCESS_TOKEN }}
E2E_GITHUB_ORGANIZATION: ${{ secrets.E2E_GITHUB_ORGANIZATION }}
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN }}
E2E_ORGANIZATION_ID: ${{ secrets.E2E_ORGANIZATION_ID }}
E2E_OCI_TENANCY_ID: ${{ secrets.E2E_OCI_TENANCY_ID }}
E2E_OCI_USER_ID: ${{ secrets.E2E_OCI_USER_ID }}
E2E_OCI_FINGERPRINT: ${{ secrets.E2E_OCI_FINGERPRINT }}
E2E_OCI_KEY_CONTENT: ${{ secrets.E2E_OCI_KEY_CONTENT }}
E2E_OCI_REGION: ${{ secrets.E2E_OCI_REGION }}
E2E_NEW_USER_PASSWORD: ${{ secrets.E2E_NEW_USER_PASSWORD }}
# Pass E2E paths from impact analysis
E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }}
RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }}
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Show test scope
run: |
echo "## E2E Test Scope" >> $GITHUB_STEP_SUMMARY
if [[ "${{ env.RUN_ALL_TESTS }}" == "true" ]]; then
echo "Running **ALL** E2E tests (critical path changed)" >> $GITHUB_STEP_SUMMARY
else
echo "Running tests matching: \`${{ env.E2E_TEST_PATHS }}\`" >> $GITHUB_STEP_SUMMARY
fi
echo ""
echo "Affected modules: \`${{ needs.impact-analysis.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1
with:
cluster_name: kind
- name: Modify kubeconfig
run: |
kubectl config set-cluster kind-kind --server=https://kind-control-plane:6443
kubectl config view
- name: Add network kind to docker compose
run: |
yq -i '.networks.kind.external = true' docker-compose.yml
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
- name: Fix API data directory permissions
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
- name: Add AWS credentials for testing
run: |
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
- name: Start API services
run: |
export PROWLER_API_VERSION=latest
docker compose up -d api worker worker-beat
- name: Wait for API to be ready
run: |
echo "Waiting for prowler-api..."
timeout=150
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then
echo "Prowler API is ready!"
exit 0
fi
echo "Waiting... (${elapsed}s elapsed)"
sleep 5
elapsed=$((elapsed + 5))
done
echo "Timeout waiting for prowler-api"
exit 1
- name: Load database fixtures
run: |
docker compose exec -T api sh -c '
for fixture in api/fixtures/dev/*.json; do
if [ -f "$fixture" ]; then
echo "Loading $fixture"
poetry run python manage.py loaddata "$fixture" --database admin
fi
done
'
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: '24.13.0'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm and Next.js cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ env.STORE_PATH }}
./ui/node_modules
./ui/.next/cache
key: ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-${{ hashFiles('ui/**/*.ts', 'ui/**/*.tsx', 'ui/**/*.js', 'ui/**/*.jsx') }}
restore-keys: |
${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-
${{ runner.os }}-pnpm-nextjs-
- name: Install UI dependencies
working-directory: ./ui
run: pnpm install --frozen-lockfile --prefer-offline
- name: Build UI application
working-directory: ./ui
run: pnpm run build
- name: Cache Playwright browsers
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright browsers
working-directory: ./ui
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm run test:e2e:install
- name: Run E2E tests
working-directory: ./ui
run: |
if [[ "${{ env.RUN_ALL_TESTS }}" == "true" ]]; then
echo "Running ALL E2E tests..."
pnpm run test:e2e
else
echo "Running targeted E2E tests: ${{ env.E2E_TEST_PATHS }}"
# Convert glob patterns to playwright test paths
# e.g., "ui/tests/providers/**" -> "tests/providers"
TEST_PATHS="${{ env.E2E_TEST_PATHS }}"
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u | tr '\n' ' ')
echo "Resolved test paths: $TEST_PATHS"
pnpm exec playwright test $TEST_PATHS
fi
- name: Upload test reports
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: failure()
with:
name: playwright-report
path: ui/playwright-report/
retention-days: 30
- name: Cleanup services
if: always()
run: |
docker compose down -v || true
# Skip job - provides clear feedback when no E2E tests needed
skip-e2e:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
needs.impact-analysis.outputs.run-all != 'true'
runs-on: ubuntu-latest
steps:
- name: No E2E tests needed
run: |
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No UI E2E tests needed for this change." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Affected modules: \`${{ needs.impact-analysis.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> $GITHUB_STEP_SUMMARY
+1 -1
View File
@@ -62,4 +62,4 @@ We strive to resolve all problems as quickly as possible, and we would like to p
---
For more information about our security policies, please refer to our [Security](https://docs.prowler.com/projects/prowler-open-source/en/latest/security/) section in our documentation.
For more information about our security policies, please refer to our [Security](https://docs.prowler.com/security) section in our documentation.
+19 -1
View File
@@ -2,17 +2,35 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.19.0] (Prowler UNRELEASED)
## [Unreleased]
### 🚀 Added
- Image provider support with registry credential handling, UID validation, and connection testing
---
## [1.19.0] (Prowler v5.18.0)
### 🚀 Added
- Cloudflare provider support [(#9907)](https://github.com/prowler-cloud/prowler/pull/9907)
- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885)
- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864)
- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444)
- `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
- Partial index on findings to speed up new failed findings queries [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904)
### 🔄 Changed
- Lazy-load providers and compliance data to reduce API/worker startup memory and time [(#9857)](https://github.com/prowler-cloud/prowler/pull/9857)
- Attack Paths: Pinned Cartography to version `0.126.1`, adding AWS scans for SageMaker, CloudFront and Bedrock [(#9893)](https://github.com/prowler-cloud/prowler/issues/9893)
- Remove unused indexes [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904)
- Attack Paths: Modified the behaviour of the Cartography scans to use the same Neo4j database per tenant, instead of individual databases per scans [(#9955)](https://github.com/prowler-cloud/prowler/pull/9955)
### 🐞 Fixed
- Attack Paths: `aws-security-groups-open-internet-facing` query returning no results due to incorrect relationship matching [(#9892)](https://github.com/prowler-cloud/prowler/pull/9892)
---
+2482 -1791
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -5,7 +5,7 @@ requires = ["poetry-core"]
[project]
authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
dependencies = [
"celery[pytest] (>=5.4.0,<6.0.0)",
"celery (>=5.4.0,<6.0.0)",
"dj-rest-auth[with_social,jwt] (==7.0.1)",
"django (==5.1.15)",
"django-allauth[saml] (>=65.13.0,<66.0.0)",
@@ -24,7 +24,7 @@ dependencies = [
"drf-spectacular-jsonapi==0.5.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@feat/PROWLER-940-stage-2-a-image-provider-api-support",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
@@ -37,7 +37,7 @@ dependencies = [
"matplotlib (>=3.10.6,<4.0.0)",
"reportlab (>=4.4.4,<5.0.0)",
"neo4j (<6.0.0)",
"cartography @ git+https://github.com/prowler-cloud/cartography@master",
"cartography @ git+https://github.com/prowler-cloud/cartography@0.126.1",
"gevent (>=25.9.1,<26.0.0)",
"werkzeug (>=3.1.4)",
"sqlparse (>=0.5.4)",
@@ -49,7 +49,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.19.0"
version = "1.20.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
@@ -59,6 +59,7 @@ bandit = "1.7.9"
coverage = "7.5.4"
django-silk = "5.3.2"
docker = "7.1.0"
filelock = "3.20.3"
freezegun = "1.5.1"
marshmallow = ">=3.15.0,<4.0.0"
mypy = "1.10.1"
@@ -71,6 +72,5 @@ pytest-randomly = "3.15.0"
pytest-xdist = "3.6.1"
ruff = "0.5.0"
safety = "3.7.0"
filelock = "3.20.3"
vulture = "2.14"
tqdm = "4.67.1"
vulture = "2.14"
+2 -1
View File
@@ -1,10 +1,11 @@
from api.attack_paths.query_definitions import (
from api.attack_paths.queries import (
AttackPathsQueryDefinition,
AttackPathsQueryParameterDefinition,
get_queries_for_provider,
get_query_by_id,
)
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryParameterDefinition",
+40 -20
View File
@@ -1,15 +1,18 @@
import atexit
import logging
import threading
from contextlib import contextmanager
from typing import Iterator
from uuid import UUID
import neo4j
import neo4j.exceptions
from django.conf import settings
from api.attack_paths.retryable_session import RetryableSession
from tasks.jobs.attack_paths.config import BATCH_SIZE, PROVIDER_RESOURCE_LABEL
# Without this Celery goes crazy with Neo4j logging
logging.getLogger("neo4j").setLevel(logging.ERROR)
@@ -83,7 +86,8 @@ def get_session(database: str | None = None) -> Iterator[RetryableSession]:
yield session_wrapper
except neo4j.exceptions.Neo4jError as exc:
raise GraphDatabaseQueryException(message=exc.message, code=exc.code)
message = exc.message if exc.message is not None else str(exc)
raise GraphDatabaseQueryException(message=message, code=exc.code)
finally:
if session_wrapper is not None:
@@ -105,24 +109,41 @@ def drop_database(database: str) -> None:
session.run(query)
def drop_subgraph(database: str, root_node_label: str, root_node_id: str) -> int:
query = """
MATCH (a:__ROOT_NODE_LABEL__ {id: $root_node_id})
CALL apoc.path.subgraphNodes(a, {})
YIELD node
DETACH DELETE node
RETURN COUNT(node) AS deleted_nodes_count
""".replace("__ROOT_NODE_LABEL__", root_node_label)
parameters = {"root_node_id": root_node_id}
def drop_subgraph(database: str, provider_id: str) -> int:
"""
Delete all nodes for a provider from the tenant database.
with get_session(database) as session:
result = session.run(query, parameters)
Uses batched deletion to avoid memory issues with large graphs.
Silently returns 0 if the database doesn't exist.
"""
deleted_nodes = 0
parameters = {
"provider_id": provider_id,
"batch_size": BATCH_SIZE,
}
try:
return result.single()["deleted_nodes_count"]
try:
with get_session(database) as session:
deleted_count = 1
while deleted_count > 0:
result = session.run(
f"""
MATCH (n:{PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}})
WITH n LIMIT $batch_size
DETACH DELETE n
RETURN COUNT(n) AS deleted_nodes_count
""",
parameters,
)
deleted_count = result.single().get("deleted_nodes_count", 0)
deleted_nodes += deleted_count
except neo4j.exceptions.ResultConsumedError:
return 0 # As there are no nodes to delete, the result is empty
except GraphDatabaseQueryException as exc:
if exc.code == "Neo.ClientError.Database.DatabaseNotFound":
return 0
raise
return deleted_nodes
def clear_cache(database: str) -> None:
@@ -137,12 +158,11 @@ def clear_cache(database: str) -> None:
# Neo4j functions related to Prowler + Cartography
DATABASE_NAME_TEMPLATE = "db-{attack_paths_scan_id}"
def get_database_name(attack_paths_scan_id: UUID) -> str:
attack_paths_scan_id_str = str(attack_paths_scan_id).lower()
return DATABASE_NAME_TEMPLATE.format(attack_paths_scan_id=attack_paths_scan_id_str)
def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str:
prefix = "tmp-scan" if temporary else "tenant"
return f"db-{prefix}-{str(entity_id).lower()}"
# Exceptions
@@ -0,0 +1,16 @@
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryParameterDefinition,
)
from api.attack_paths.queries.registry import (
get_queries_for_provider,
get_query_by_id,
)
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
]
@@ -0,0 +1,695 @@
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryParameterDefinition,
)
from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
# Privilege Escalation Queries (based on pathfinding.cloud research)
# https://github.com/DataDog/pathfinding.cloud
# -------------------------------------------------------------------
AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
id="aws-internet-exposed-ec2-sensitive-s3-access",
name="Identify internet-exposed EC2 instances with sensitive S3 access",
description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.",
provider="aws",
cypher=f"""
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
YIELD node AS internet
MATCH path_s3 = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)--(t:AWSTag)
WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value)
MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)
WHERE ec2.exposed_internet = true
AND ipi.toport = 22
MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name)
AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*')
MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole)
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2)
YIELD rel AS can_access
UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[
AttackPathsQueryParameterDefinition(
name="tag_key",
label="Tag key",
description="Tag key to filter the S3 bucket, e.g. DataClassification.",
placeholder="DataClassification",
),
AttackPathsQueryParameterDefinition(
name="tag_value",
label="Tag value",
description="Tag value to filter the S3 bucket, e.g. Sensitive.",
placeholder="Sensitive",
),
],
)
# Basic Resource Queries
# ----------------------
AWS_RDS_INSTANCES = AttackPathsQueryDefinition(
id="aws-rds-instances",
name="Identify provisioned RDS instances",
description="List the selected AWS account alongside the RDS instances it owns.",
provider="aws",
cypher=f"""
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance)
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition(
id="aws-rds-unencrypted-storage",
name="Identify RDS instances without storage encryption",
description="Find RDS instances with storage encryption disabled within the selected account.",
provider="aws",
cypher=f"""
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance)
WHERE rds.storage_encrypted = false
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition(
id="aws-s3-anonymous-access-buckets",
name="Identify S3 buckets with anonymous access",
description="Find S3 buckets that allow anonymous access within the selected account.",
provider="aws",
cypher=f"""
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)
WHERE s3.anonymous_access = true
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition(
id="aws-iam-statements-allow-all-actions",
name="Identify IAM statements that allow all actions",
description="Find IAM policy statements that allow all actions via '*' within the selected account.",
provider="aws",
cypher=f"""
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND any(x IN stmt.action WHERE x = '*')
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition(
id="aws-iam-statements-allow-delete-policy",
name="Identify IAM statements that allow iam:DeletePolicy",
description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.",
provider="aws",
cypher=f"""
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND any(x IN stmt.action WHERE x = "iam:DeletePolicy")
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition(
id="aws-iam-statements-allow-create-actions",
name="Identify IAM statements that allow create actions",
description="Find IAM policy statements that allow actions containing 'create' within the selected account.",
provider="aws",
cypher=f"""
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = "Allow"
AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create")
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
# Network Exposure Queries
# ------------------------
AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition(
id="aws-ec2-instances-internet-exposed",
name="Identify internet-exposed EC2 instances",
description="Find EC2 instances flagged as exposed to the internet within the selected account.",
provider="aws",
cypher=f"""
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
YIELD node AS internet
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)
WHERE ec2.exposed_internet = true
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2)
YIELD rel AS can_access
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
)
AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition(
id="aws-security-groups-open-internet-facing",
name="Identify internet-facing resources with open security groups",
description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.",
provider="aws",
cypher=f"""
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
YIELD node AS internet
// Match EC2 instances that are internet-exposed with open security groups (0.0.0.0/0)
MATCH path_ec2 = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange)
WHERE ec2.exposed_internet = true
AND ir.range = "0.0.0.0/0"
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2)
YIELD rel AS can_access
UNWIND nodes(path_ec2) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path_ec2, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
)
AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition(
id="aws-classic-elb-internet-exposed",
name="Identify internet-exposed Classic Load Balancers",
description="Find Classic Load Balancers exposed to the internet along with their listeners.",
provider="aws",
cypher=f"""
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
YIELD node AS internet
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener)
WHERE elb.exposed_internet = true
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, elb)
YIELD rel AS can_access
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
)
AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition(
id="aws-elbv2-internet-exposed",
name="Identify internet-exposed ELBv2 load balancers",
description="Find ELBv2 load balancers exposed to the internet along with their listeners.",
provider="aws",
cypher=f"""
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
YIELD node AS internet
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener)
WHERE elbv2.exposed_internet = true
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, elbv2)
YIELD rel AS can_access
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
)
AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition(
id="aws-public-ip-resource-lookup",
name="Identify resources by public IP address",
description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.",
provider="aws",
cypher=f"""
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
YIELD node AS internet
CALL () {{
MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:EC2PrivateIp)-[q]-(y)
WHERE x.public_ip = $ip
RETURN path, x
UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:EC2Instance)-[q]-(y)
WHERE x.publicipaddress = $ip
RETURN path, x
UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:NetworkInterface)-[q]-(y)
WHERE x.public_ip = $ip
RETURN path, x
UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:ElasticIPAddress)-[q]-(y)
WHERE x.public_ip = $ip
RETURN path, x
}}
WITH path, x, internet
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, x)
YIELD rel AS can_access
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[
AttackPathsQueryParameterDefinition(
name="ip",
label="IP address",
description="Public IP address, e.g. 192.0.2.0.",
placeholder="192.0.2.0",
),
],
)
AWS_IAM_PRIVESC_PASSROLE_EC2 = AttackPathsQueryDefinition(
id="aws-iam-privesc-passrole-ec2",
name="Privilege Escalation: iam:PassRole + ec2:RunInstances",
description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service. This is a new-passrole escalation path (pathfinding.cloud: ec2-001).",
provider="aws",
cypher=f"""
// Create a single shared virtual EC2 instance node
CALL apoc.create.vNode(['EC2Instance'], {{
id: 'potential-ec2-passrole',
name: 'New EC2 Instance',
description: 'Attacker-controlled EC2 with privileged role'
}})
YIELD node AS ec2_node
// Create a single shared virtual escalation outcome node (styled like a finding)
CALL apoc.create.vNode(['PrivilegeEscalation'], {{
id: 'effective-administrator-passrole-ec2',
check_title: 'Privilege Escalation',
name: 'Effective Administrator',
status: 'FAIL',
severity: 'critical'
}})
YIELD node AS escalation_outcome
WITH ec2_node, escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)
// Find statements granting iam:PassRole
MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement)
WHERE stmt_passrole.effect = 'Allow'
AND any(action IN stmt_passrole.action WHERE
toLower(action) = 'iam:passrole'
OR toLower(action) = 'iam:*'
OR action = '*'
)
// Find statements granting ec2:RunInstances
MATCH path_ec2 = (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement)
WHERE stmt_ec2.effect = 'Allow'
AND any(action IN stmt_ec2.action WHERE
toLower(action) = 'ec2:runinstances'
OR toLower(action) = 'ec2:*'
OR action = '*'
)
// Find roles that trust EC2 service (can be passed to EC2)
MATCH path_target = (aws)--(target_role:AWSRole)
WHERE target_role.arn CONTAINS $provider_uid
// Check if principal can pass this role
AND any(resource IN stmt_passrole.resource WHERE
resource = '*'
OR target_role.arn CONTAINS resource
OR resource CONTAINS target_role.name
)
// Check if target role has elevated permissions (optional, for severity assessment)
OPTIONAL MATCH (target_role)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement)
WHERE role_stmt.effect = 'Allow'
AND (
any(action IN role_stmt.action WHERE action = '*')
OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*')
)
CALL apoc.create.vRelationship(principal, 'CAN_LAUNCH', {{
via: 'ec2:RunInstances + iam:PassRole'
}}, ec2_node)
YIELD rel AS launch_rel
CALL apoc.create.vRelationship(ec2_node, 'ASSUMES_ROLE', {{}}, target_role)
YIELD rel AS assumes_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {{
reference: 'https://pathfinding.cloud/paths/ec2-001'
}}, escalation_outcome)
YIELD rel AS grants_rel
UNWIND nodes(path_principal) + nodes(path_passrole) + nodes(path_ec2) + nodes(path_target) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path_principal, path_passrole, path_ec2, path_target,
ec2_node, escalation_outcome, launch_rel, assumes_rel, grants_rel,
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
# TODO: Add ProwlerFinding nodes
AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition(
id="aws-glue-privesc-passrole-dev-endpoint",
name="Privilege Escalation: Glue Dev Endpoint with PassRole",
description="Detect principals that can escalate privileges by passing a role to a Glue development endpoint. The attacker creates a dev endpoint with an arbitrary role attached, then accesses those credentials through the endpoint.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-glue',
check_title: 'Privilege Escalation',
name: 'Effective Administrator (Glue)',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Principal can assume roles (up to 2 hops)
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
WITH escalation_outcome, principal, path_principal, path_assume,
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
// Find iam:PassRole permission
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
WHERE passrole_stmt.effect = 'Allow'
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
// Find Glue CreateDevEndpoint permission
MATCH (effective_principal)--(glue_policy:AWSPolicy)--(glue_stmt:AWSPolicyStatement)
WHERE glue_stmt.effect = 'Allow'
AND any(action IN glue_stmt.action WHERE toLower(action) = 'glue:createdevendpoint' OR action = '*' OR toLower(action) = 'glue:*')
// Find target role with elevated permissions
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
WHERE target_stmt.effect = 'Allow'
AND (
any(action IN target_stmt.action WHERE action = '*')
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
)
// Deduplicate before creating virtual nodes
WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role
// Create virtual Glue endpoint node (one per unique principal->target pair)
CALL apoc.create.vNode(['GlueDevEndpoint'], {
name: 'New Dev Endpoint',
description: 'Glue endpoint with target role attached',
id: effective_principal.arn + '->' + target_role.arn
})
YIELD node AS glue_endpoint
CALL apoc.create.vRelationship(effective_principal, 'CREATES_ENDPOINT', {
permissions: ['iam:PassRole', 'glue:CreateDevEndpoint'],
technique: 'new-passrole'
}, glue_endpoint)
YIELD rel AS create_rel
CALL apoc.create.vRelationship(glue_endpoint, 'RUNS_AS', {}, target_role)
YIELD rel AS runs_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/glue-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match paths for visualization
MATCH path_principal = (aws)--(principal)
MATCH path_target = (aws)--(target_role)
RETURN path_principal, path_target,
glue_endpoint, escalation_outcome, create_rel, runs_rel, grants_rel
""",
parameters=[],
)
AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
id="aws-iam-privesc-attach-role-policy-assume-role",
name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole",
description="Detect principals who can both attach policies to roles AND assume those roles. This two-step attack allows modifying a role's permissions then assuming it to gain elevated access. This is a principal-access escalation path (pathfinding.cloud: iam-014).",
provider="aws",
cypher=f"""
// Create a virtual escalation outcome node (styled like a finding)
CALL apoc.create.vNode(['PrivilegeEscalation'], {{
id: 'effective-administrator',
check_title: 'Privilege Escalation',
name: 'Effective Administrator',
status: 'FAIL',
severity: 'critical'
}})
YIELD node AS admin_outcome
WITH admin_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)
// Find statements granting iam:AttachRolePolicy
MATCH path_attach = (principal)--(attach_policy:AWSPolicy)--(stmt_attach:AWSPolicyStatement)
WHERE stmt_attach.effect = 'Allow'
AND any(action IN stmt_attach.action WHERE
toLower(action) = 'iam:attachrolepolicy'
OR toLower(action) = 'iam:*'
OR action = '*'
)
// Find statements granting sts:AssumeRole
MATCH path_assume = (principal)--(assume_policy:AWSPolicy)--(stmt_assume:AWSPolicyStatement)
WHERE stmt_assume.effect = 'Allow'
AND any(action IN stmt_assume.action WHERE
toLower(action) = 'sts:assumerole'
OR toLower(action) = 'sts:*'
OR action = '*'
)
// Find target roles that the principal can both modify AND assume
MATCH path_target = (aws)--(target_role:AWSRole)
WHERE target_role.arn CONTAINS $provider_uid
// Can attach policy to this role
AND any(resource IN stmt_attach.resource WHERE
resource = '*'
OR target_role.arn CONTAINS resource
OR resource CONTAINS target_role.name
)
// Can assume this role
AND any(resource IN stmt_assume.resource WHERE
resource = '*'
OR target_role.arn CONTAINS resource
OR resource CONTAINS target_role.name
)
// Deduplicate before creating virtual relationships
WITH DISTINCT admin_outcome, aws, principal, target_role
// Create virtual relationships showing the attack path
CALL apoc.create.vRelationship(principal, 'CAN_MODIFY', {{
via: 'iam:AttachRolePolicy'
}}, target_role)
YIELD rel AS modify_rel
CALL apoc.create.vRelationship(target_role, 'LEADS_TO', {{
technique: 'iam:AttachRolePolicy + sts:AssumeRole',
via: 'sts:AssumeRole',
reference: 'https://pathfinding.cloud/paths/iam-014'
}}, admin_outcome)
YIELD rel AS escalation_rel
// Re-match paths for visualization
MATCH path_principal = (aws)--(principal)
MATCH path_target = (aws)--(target_role)
UNWIND nodes(path_principal) + nodes(path_target) as n
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
WHERE pf.status = 'FAIL'
RETURN path_principal, path_target,
admin_outcome, modify_rel, escalation_rel,
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
# TODO: Add ProwlerFinding nodes
AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition(
id="aws-bedrock-privesc-passrole-code-interpreter",
name="Privilege Escalation: Bedrock Code Interpreter with PassRole",
description="Detect principals that can escalate privileges by passing a role to a Bedrock AgentCore Code Interpreter. The attacker creates a code interpreter with an arbitrary role, then invokes it to execute code with those credentials.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-bedrock',
check_title: 'Privilege Escalation',
name: 'Effective Administrator (Bedrock)',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Principal can assume roles (up to 2 hops)
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
WITH escalation_outcome, aws, principal, path_principal, path_assume,
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
// Find iam:PassRole permission
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
WHERE passrole_stmt.effect = 'Allow'
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
// Find Bedrock AgentCore permissions
MATCH (effective_principal)--(bedrock_policy:AWSPolicy)--(bedrock_stmt:AWSPolicyStatement)
WHERE bedrock_stmt.effect = 'Allow'
AND (
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:createcodeinterpreter' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
)
AND (
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:startsession' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
)
AND (
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:invoke' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
)
// Find target roles with elevated permissions that could be passed
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
WHERE target_stmt.effect = 'Allow'
AND (
any(action IN target_stmt.action WHERE action = '*')
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
)
// Deduplicate per (principal, target_role) pair
WITH DISTINCT escalation_outcome, aws, principal, target_role
// Group by principal, collect target_roles
WITH escalation_outcome, aws, principal,
collect(DISTINCT target_role) AS target_roles,
count(DISTINCT target_role) AS target_count
// Create single virtual Bedrock node per principal
CALL apoc.create.vNode(['BedrockCodeInterpreter'], {
name: 'New Code Interpreter',
description: toString(target_count) + ' admin role(s) can be passed',
id: principal.arn,
target_role_count: target_count
})
YIELD node AS bedrock_agent
// Connect from principal (not effective_principal) to keep graph connected
CALL apoc.create.vRelationship(principal, 'CREATES_INTERPRETER', {
permissions: ['iam:PassRole', 'bedrock-agentcore:CreateCodeInterpreter', 'bedrock-agentcore:StartSession', 'bedrock-agentcore:Invoke'],
technique: 'new-passrole'
}, bedrock_agent)
YIELD rel AS create_rel
// UNWIND target_roles to show which roles can be passed
UNWIND target_roles AS target_role
CALL apoc.create.vRelationship(bedrock_agent, 'PASSES_ROLE', {}, target_role)
YIELD rel AS pass_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/bedrock-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match path for visualization
MATCH path_principal = (aws)--(principal)
RETURN path_principal,
bedrock_agent, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
""",
parameters=[],
)
# AWS Queries List
# ----------------
AWS_QUERIES: list[AttackPathsQueryDefinition] = [
AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS,
AWS_RDS_INSTANCES,
AWS_RDS_UNENCRYPTED_STORAGE,
AWS_S3_ANONYMOUS_ACCESS_BUCKETS,
AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS,
AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY,
AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS,
AWS_EC2_INSTANCES_INTERNET_EXPOSED,
AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING,
AWS_CLASSIC_ELB_INTERNET_EXPOSED,
AWS_ELBV2_INTERNET_EXPOSED,
AWS_PUBLIC_IP_RESOURCE_LOOKUP,
AWS_IAM_PRIVESC_PASSROLE_EC2,
AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT,
AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE,
AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER,
]
@@ -0,0 +1,25 @@
from api.attack_paths.queries.types import AttackPathsQueryDefinition
from api.attack_paths.queries.aws import AWS_QUERIES
# Query definitions organized by provider
_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
"aws": AWS_QUERIES,
}
# Flat lookup by query ID for O(1) access
_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = {
definition.id: definition
for definitions in _QUERY_DEFINITIONS.values()
for definition in definitions
}
def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]:
"""Get all attack path queries for a specific provider."""
return _QUERY_DEFINITIONS.get(provider, [])
def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None:
"""Get a specific attack path query by its ID."""
return _QUERIES_BY_ID.get(query_id)
@@ -0,0 +1,29 @@
from dataclasses import dataclass, field
@dataclass
class AttackPathsQueryParameterDefinition:
"""
Metadata describing a parameter that must be provided to an Attack Paths query.
"""
name: str
label: str
data_type: str = "string"
cast: type = str
description: str | None = None
placeholder: str | None = None
@dataclass
class AttackPathsQueryDefinition:
"""
Immutable representation of an Attack Path query.
"""
id: str
name: str
description: str
provider: str
cypher: str
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
@@ -1,693 +0,0 @@
from dataclasses import dataclass, field
# Dataclases for handling API's Attack Path query definitions and their parameters
@dataclass
class AttackPathsQueryParameterDefinition:
"""
Metadata describing a parameter that must be provided to an Attack Paths query.
"""
name: str
label: str
data_type: str = "string"
cast: type = str
description: str | None = None
placeholder: str | None = None
@dataclass
class AttackPathsQueryDefinition:
"""
Immutable representation of an Attack Path query.
"""
id: str
name: str
description: str
provider: str
cypher: str
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
# Accessor functions for API's Attack Paths query definitions
def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]:
return _QUERY_DEFINITIONS.get(provider, [])
def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None:
return _QUERIES_BY_ID.get(query_id)
# API's Attack Paths query definitions
_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
"aws": [
# Custom query for detecting internet-exposed EC2 instances with sensitive S3 access
AttackPathsQueryDefinition(
id="aws-internet-exposed-ec2-sensitive-s3-access",
name="Identify internet-exposed EC2 instances with sensitive S3 access",
description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
YIELD node AS internet
MATCH path_s3 = (aws:AWSAccount {id: $provider_uid})--(s3:S3Bucket)--(t:AWSTag)
WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value)
MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)
WHERE ec2.exposed_internet = true
AND ipi.toport = 22
MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name)
AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*')
MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole)
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2)
YIELD rel AS can_access
UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[
AttackPathsQueryParameterDefinition(
name="tag_key",
label="Tag key",
description="Tag key to filter the S3 bucket, e.g. DataClassification.",
placeholder="DataClassification",
),
AttackPathsQueryParameterDefinition(
name="tag_value",
label="Tag value",
description="Tag value to filter the S3 bucket, e.g. Sensitive.",
placeholder="Sensitive",
),
],
),
# Regular Cartography Attack Paths queries
AttackPathsQueryDefinition(
id="aws-rds-instances",
name="Identify provisioned RDS instances",
description="List the selected AWS account alongside the RDS instances it owns.",
provider="aws",
cypher="""
MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-rds-unencrypted-storage",
name="Identify RDS instances without storage encryption",
description="Find RDS instances with storage encryption disabled within the selected account.",
provider="aws",
cypher="""
MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)
WHERE rds.storage_encrypted = false
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-s3-anonymous-access-buckets",
name="Identify S3 buckets with anonymous access",
description="Find S3 buckets that allow anonymous access within the selected account.",
provider="aws",
cypher="""
MATCH path = (aws:AWSAccount {id: $provider_uid})--(s3:S3Bucket)
WHERE s3.anonymous_access = true
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-iam-statements-allow-all-actions",
name="Identify IAM statements that allow all actions",
description="Find IAM policy statements that allow all actions via '*' within the selected account.",
provider="aws",
cypher="""
MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND any(x IN stmt.action WHERE x = '*')
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-iam-statements-allow-delete-policy",
name="Identify IAM statements that allow iam:DeletePolicy",
description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.",
provider="aws",
cypher="""
MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND any(x IN stmt.action WHERE x = "iam:DeletePolicy")
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-iam-statements-allow-create-actions",
name="Identify IAM statements that allow create actions",
description="Find IAM policy statements that allow actions containing 'create' within the selected account.",
provider="aws",
cypher="""
MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = "Allow"
AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create")
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-ec2-instances-internet-exposed",
name="Identify internet-exposed EC2 instances",
description="Find EC2 instances flagged as exposed to the internet within the selected account.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
YIELD node AS internet
MATCH path = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)
WHERE ec2.exposed_internet = true
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2)
YIELD rel AS can_access
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-security-groups-open-internet-facing",
name="Identify internet-facing resources with open security groups",
description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
YIELD node AS internet
MATCH path_open = (aws:AWSAccount {id: $provider_uid})-[r0]-(open)
MATCH path_sg = (open)-[r1:MEMBER_OF_EC2_SECURITY_GROUP]-(sg:EC2SecurityGroup)
MATCH path_ip = (sg)-[r2:MEMBER_OF_EC2_SECURITY_GROUP]-(ipi:IpPermissionInbound)
MATCH path_ipi = (ipi)-[r3]-(ir:IpRange)
WHERE ir.range = "0.0.0.0/0"
OPTIONAL MATCH path_dns = (dns:AWSDNSRecord)-[:DNS_POINTS_TO]->(lb)
WHERE open.scheme = 'internet-facing'
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, open)
YIELD rel AS can_access
UNWIND nodes(path_open) + nodes(path_sg) + nodes(path_ip) + nodes(path_ipi) + nodes(path_dns) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path_open, path_sg, path_ip, path_ipi, path_dns, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-classic-elb-internet-exposed",
name="Identify internet-exposed Classic Load Balancers",
description="Find Classic Load Balancers exposed to the internet along with their listeners.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
YIELD node AS internet
MATCH path = (aws:AWSAccount {id: $provider_uid})--(elb:LoadBalancer)--(listener:ELBListener)
WHERE elb.exposed_internet = true
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, elb)
YIELD rel AS can_access
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-elbv2-internet-exposed",
name="Identify internet-exposed ELBv2 load balancers",
description="Find ELBv2 load balancers exposed to the internet along with their listeners.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
YIELD node AS internet
MATCH path = (aws:AWSAccount {id: $provider_uid})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener)
WHERE elbv2.exposed_internet = true
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, elbv2)
YIELD rel AS can_access
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-public-ip-resource-lookup",
name="Identify resources by public IP address",
description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
YIELD node AS internet
CALL () {
MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:EC2PrivateIp)-[q]-(y)
WHERE x.public_ip = $ip
RETURN path, x
UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:EC2Instance)-[q]-(y)
WHERE x.publicipaddress = $ip
RETURN path, x
UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:NetworkInterface)-[q]-(y)
WHERE x.public_ip = $ip
RETURN path, x
UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:ElasticIPAddress)-[q]-(y)
WHERE x.public_ip = $ip
RETURN path, x
}
WITH path, x, internet
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, x)
YIELD rel AS can_access
UNWIND nodes(path) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
parameters=[
AttackPathsQueryParameterDefinition(
name="ip",
label="IP address",
description="Public IP address, e.g. 192.0.2.0.",
placeholder="192.0.2.0",
),
],
),
# Privilege Escalation Queries (based on pathfinding.cloud research): https://github.com/DataDog/pathfinding.cloud
AttackPathsQueryDefinition(
id="aws-iam-privesc-passrole-ec2",
name="Privilege Escalation: iam:PassRole + ec2:RunInstances",
description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service. This is a new-passrole escalation path (pathfinding.cloud: ec2-001).",
provider="aws",
cypher="""
// Create a single shared virtual EC2 instance node
CALL apoc.create.vNode(['EC2Instance'], {
id: 'potential-ec2-passrole',
name: 'New EC2 Instance',
description: 'Attacker-controlled EC2 with privileged role'
})
YIELD node AS ec2_node
// Create a single shared virtual escalation outcome node (styled like a finding)
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-passrole-ec2',
check_title: 'Privilege Escalation',
name: 'Effective Administrator',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH ec2_node, escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Find statements granting iam:PassRole
MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement)
WHERE stmt_passrole.effect = 'Allow'
AND any(action IN stmt_passrole.action WHERE
toLower(action) = 'iam:passrole'
OR toLower(action) = 'iam:*'
OR action = '*'
)
// Find statements granting ec2:RunInstances
MATCH path_ec2 = (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement)
WHERE stmt_ec2.effect = 'Allow'
AND any(action IN stmt_ec2.action WHERE
toLower(action) = 'ec2:runinstances'
OR toLower(action) = 'ec2:*'
OR action = '*'
)
// Find roles that trust EC2 service (can be passed to EC2)
MATCH path_target = (aws)--(target_role:AWSRole)
WHERE target_role.arn CONTAINS $provider_uid
// Check if principal can pass this role
AND any(resource IN stmt_passrole.resource WHERE
resource = '*'
OR target_role.arn CONTAINS resource
OR resource CONTAINS target_role.name
)
// Check if target role has elevated permissions (optional, for severity assessment)
OPTIONAL MATCH (target_role)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement)
WHERE role_stmt.effect = 'Allow'
AND (
any(action IN role_stmt.action WHERE action = '*')
OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*')
)
CALL apoc.create.vRelationship(principal, 'CAN_LAUNCH', {
via: 'ec2:RunInstances + iam:PassRole'
}, ec2_node)
YIELD rel AS launch_rel
CALL apoc.create.vRelationship(ec2_node, 'ASSUMES_ROLE', {}, target_role)
YIELD rel AS assumes_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/ec2-001'
}, escalation_outcome)
YIELD rel AS grants_rel
UNWIND nodes(path_principal) + nodes(path_passrole) + nodes(path_ec2) + nodes(path_target) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path_principal, path_passrole, path_ec2, path_target,
ec2_node, escalation_outcome, launch_rel, assumes_rel, grants_rel,
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-glue-privesc-passrole-dev-endpoint",
name="Privilege Escalation: Glue Dev Endpoint with PassRole",
description="Detect principals that can escalate privileges by passing a role to a Glue development endpoint. The attacker creates a dev endpoint with an arbitrary role attached, then accesses those credentials through the endpoint.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-glue',
check_title: 'Privilege Escalation',
name: 'Effective Administrator (Glue)',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Principal can assume roles (up to 2 hops)
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
WITH escalation_outcome, principal, path_principal, path_assume,
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
// Find iam:PassRole permission
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
WHERE passrole_stmt.effect = 'Allow'
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
// Find Glue CreateDevEndpoint permission
MATCH (effective_principal)--(glue_policy:AWSPolicy)--(glue_stmt:AWSPolicyStatement)
WHERE glue_stmt.effect = 'Allow'
AND any(action IN glue_stmt.action WHERE toLower(action) = 'glue:createdevendpoint' OR action = '*' OR toLower(action) = 'glue:*')
// Find target role with elevated permissions
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
WHERE target_stmt.effect = 'Allow'
AND (
any(action IN target_stmt.action WHERE action = '*')
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
)
// Deduplicate before creating virtual nodes
WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role
// Create virtual Glue endpoint node (one per unique principal->target pair)
CALL apoc.create.vNode(['GlueDevEndpoint'], {
name: 'New Dev Endpoint',
description: 'Glue endpoint with target role attached',
id: effective_principal.arn + '->' + target_role.arn
})
YIELD node AS glue_endpoint
CALL apoc.create.vRelationship(effective_principal, 'CREATES_ENDPOINT', {
permissions: ['iam:PassRole', 'glue:CreateDevEndpoint'],
technique: 'new-passrole'
}, glue_endpoint)
YIELD rel AS create_rel
CALL apoc.create.vRelationship(glue_endpoint, 'RUNS_AS', {}, target_role)
YIELD rel AS runs_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/glue-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match paths for visualization
MATCH path_principal = (aws)--(principal)
MATCH path_target = (aws)--(target_role)
RETURN path_principal, path_target,
glue_endpoint, escalation_outcome, create_rel, runs_rel, grants_rel
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-iam-privesc-attach-role-policy-assume-role",
name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole",
description="Detect principals who can both attach policies to roles AND assume those roles. This two-step attack allows modifying a role's permissions then assuming it to gain elevated access. This is a principal-access escalation path (pathfinding.cloud: iam-014).",
provider="aws",
cypher="""
// Create a virtual escalation outcome node (styled like a finding)
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator',
check_title: 'Privilege Escalation',
name: 'Effective Administrator',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS admin_outcome
WITH admin_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Find statements granting iam:AttachRolePolicy
MATCH path_attach = (principal)--(attach_policy:AWSPolicy)--(stmt_attach:AWSPolicyStatement)
WHERE stmt_attach.effect = 'Allow'
AND any(action IN stmt_attach.action WHERE
toLower(action) = 'iam:attachrolepolicy'
OR toLower(action) = 'iam:*'
OR action = '*'
)
// Find statements granting sts:AssumeRole
MATCH path_assume = (principal)--(assume_policy:AWSPolicy)--(stmt_assume:AWSPolicyStatement)
WHERE stmt_assume.effect = 'Allow'
AND any(action IN stmt_assume.action WHERE
toLower(action) = 'sts:assumerole'
OR toLower(action) = 'sts:*'
OR action = '*'
)
// Find target roles that the principal can both modify AND assume
MATCH path_target = (aws)--(target_role:AWSRole)
WHERE target_role.arn CONTAINS $provider_uid
// Can attach policy to this role
AND any(resource IN stmt_attach.resource WHERE
resource = '*'
OR target_role.arn CONTAINS resource
OR resource CONTAINS target_role.name
)
// Can assume this role
AND any(resource IN stmt_assume.resource WHERE
resource = '*'
OR target_role.arn CONTAINS resource
OR resource CONTAINS target_role.name
)
// Deduplicate before creating virtual relationships
WITH DISTINCT admin_outcome, aws, principal, target_role
// Create virtual relationships showing the attack path
CALL apoc.create.vRelationship(principal, 'CAN_MODIFY', {
via: 'iam:AttachRolePolicy'
}, target_role)
YIELD rel AS modify_rel
CALL apoc.create.vRelationship(target_role, 'LEADS_TO', {
technique: 'iam:AttachRolePolicy + sts:AssumeRole',
via: 'sts:AssumeRole',
reference: 'https://pathfinding.cloud/paths/iam-014'
}, admin_outcome)
YIELD rel AS escalation_rel
// Re-match paths for visualization
MATCH path_principal = (aws)--(principal)
MATCH path_target = (aws)--(target_role)
UNWIND nodes(path_principal) + nodes(path_target) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path_principal, path_target,
admin_outcome, modify_rel, escalation_rel,
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-bedrock-privesc-passrole-code-interpreter",
name="Privilege Escalation: Bedrock Code Interpreter with PassRole",
description="Detect principals that can escalate privileges by passing a role to a Bedrock AgentCore Code Interpreter. The attacker creates a code interpreter with an arbitrary role, then invokes it to execute code with those credentials.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-bedrock',
check_title: 'Privilege Escalation',
name: 'Effective Administrator (Bedrock)',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Principal can assume roles (up to 2 hops)
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
WITH escalation_outcome, aws, principal, path_principal, path_assume,
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
// Find iam:PassRole permission
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
WHERE passrole_stmt.effect = 'Allow'
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
// Find Bedrock AgentCore permissions
MATCH (effective_principal)--(bedrock_policy:AWSPolicy)--(bedrock_stmt:AWSPolicyStatement)
WHERE bedrock_stmt.effect = 'Allow'
AND (
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:createcodeinterpreter' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
)
AND (
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:startsession' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
)
AND (
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:invoke' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
)
// Find target roles with elevated permissions that could be passed
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
WHERE target_stmt.effect = 'Allow'
AND (
any(action IN target_stmt.action WHERE action = '*')
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
)
// Deduplicate per (principal, target_role) pair
WITH DISTINCT escalation_outcome, aws, principal, target_role
// Group by principal, collect target_roles
WITH escalation_outcome, aws, principal,
collect(DISTINCT target_role) AS target_roles,
count(DISTINCT target_role) AS target_count
// Create single virtual Bedrock node per principal
CALL apoc.create.vNode(['BedrockCodeInterpreter'], {
name: 'New Code Interpreter',
description: toString(target_count) + ' admin role(s) can be passed',
id: principal.arn,
target_role_count: target_count
})
YIELD node AS bedrock_agent
// Connect from principal (not effective_principal) to keep graph connected
CALL apoc.create.vRelationship(principal, 'CREATES_INTERPRETER', {
permissions: ['iam:PassRole', 'bedrock-agentcore:CreateCodeInterpreter', 'bedrock-agentcore:StartSession', 'bedrock-agentcore:Invoke'],
technique: 'new-passrole'
}, bedrock_agent)
YIELD rel AS create_rel
// UNWIND target_roles to show which roles can be passed
UNWIND target_roles AS target_role
CALL apoc.create.vRelationship(bedrock_agent, 'PASSES_ROLE', {}, target_role)
YIELD rel AS pass_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/bedrock-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match path for visualization
MATCH path_principal = (aws)--(principal)
RETURN path_principal,
bedrock_agent, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
""",
parameters=[],
),
],
}
_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = {
definition.id: definition
for definitions in _QUERY_DEFINITIONS.values()
for definition in definitions
}
@@ -1,12 +1,13 @@
import logging
from typing import Any
from typing import Any, Iterable
from rest_framework.exceptions import APIException, ValidationError
from api.attack_paths import database as graph_database, AttackPathsQueryDefinition
from api.models import AttackPathsScan
from config.custom_logging import BackendLogger
from tasks.jobs.attack_paths.config import INTERNAL_LABELS
logger = logging.getLogger(BackendLogger.API)
@@ -101,7 +102,7 @@ def _serialize_graph(graph):
nodes.append(
{
"id": node.element_id,
"labels": list(node.labels),
"labels": _filter_labels(node.labels),
"properties": _serialize_properties(node._properties),
},
)
@@ -124,6 +125,10 @@ def _serialize_graph(graph):
}
def _filter_labels(labels: Iterable[str]) -> list[str]:
return [label for label in labels if label not in INTERNAL_LABELS]
def _serialize_properties(properties: dict[str, Any]) -> dict[str, Any]:
"""Convert Neo4j property values into JSON-serializable primitives."""
+16 -5
View File
@@ -12,7 +12,6 @@ from django.contrib.auth.models import BaseUserManager
from django.db import (
DEFAULT_DB_ALIAS,
OperationalError,
connection,
connections,
models,
transaction,
@@ -450,7 +449,7 @@ def create_index_on_partitions(
all_partitions=True
)
"""
with connection.cursor() as cursor:
with schema_editor.connection.cursor() as cursor:
cursor.execute(
"""
SELECT inhrelid::regclass::text
@@ -462,6 +461,7 @@ def create_index_on_partitions(
partitions = [row[0] for row in cursor.fetchall()]
where_sql = f" WHERE {where}" if where else ""
conn = schema_editor.connection
for partition in partitions:
if _should_create_index_on_partition(partition, all_partitions):
idx_name = f"{partition.replace('.', '_')}_{index_name}"
@@ -470,7 +470,12 @@ def create_index_on_partitions(
f"ON {partition} USING {method} ({columns})"
f"{where_sql};"
)
schema_editor.execute(sql)
old_autocommit = conn.connection.autocommit
conn.connection.autocommit = True
try:
schema_editor.execute(sql)
finally:
conn.connection.autocommit = old_autocommit
def drop_index_on_partitions(
@@ -486,7 +491,8 @@ def drop_index_on_partitions(
parent_table: The name of the root table (e.g. "findings").
index_name: The same short name used when creating them.
"""
with connection.cursor() as cursor:
conn = schema_editor.connection
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT inhrelid::regclass::text
@@ -500,7 +506,12 @@ def drop_index_on_partitions(
for partition in partitions:
idx_name = f"{partition.replace('.', '_')}_{index_name}"
sql = f"DROP INDEX CONCURRENTLY IF EXISTS {idx_name};"
schema_editor.execute(sql)
old_autocommit = conn.connection.autocommit
conn.connection.autocommit = True
try:
schema_editor.execute(sql)
finally:
conn.connection.autocommit = old_autocommit
def generate_api_key_prefix():
+4
View File
@@ -453,6 +453,8 @@ class ResourceTagFilter(FilterSet):
class ResourceFilter(ProviderRelationshipFilterSet):
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
tag_key = CharFilter(method="filter_tag_key")
tag_value = CharFilter(method="filter_tag_value")
tag = CharFilter(method="filter_tag")
@@ -540,6 +542,8 @@ class ResourceFilter(ProviderRelationshipFilterSet):
class LatestResourceFilter(ProviderRelationshipFilterSet):
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
tag_key = CharFilter(method="filter_tag_key")
tag_value = CharFilter(method="filter_tag_value")
tag = CharFilter(method="filter_tag")
@@ -0,0 +1,41 @@
from django.db import migrations
class Migration(migrations.Migration):
"""
Drop unused indexes on partitioned tables (findings, resource_finding_mappings).
NOTE: RemoveIndexConcurrently cannot be used on partitioned tables in PostgreSQL.
Standard RemoveIndex drops the parent index, which cascades to all partitions.
"""
dependencies = [
("api", "0070_attack_paths_scan"),
]
operations = [
migrations.RemoveIndex(
model_name="finding",
name="gin_findings_search_idx",
),
migrations.RemoveIndex(
model_name="finding",
name="gin_find_service_idx",
),
migrations.RemoveIndex(
model_name="finding",
name="gin_find_region_idx",
),
migrations.RemoveIndex(
model_name="finding",
name="gin_find_rtype_idx",
),
migrations.RemoveIndex(
model_name="finding",
name="find_delta_new_idx",
),
migrations.RemoveIndex(
model_name="resourcefindingmapping",
name="rfm_tenant_finding_idx",
),
]
@@ -0,0 +1,91 @@
"""
Drop unused indexes on non-partitioned tables.
These tables are not partitioned, so RemoveIndexConcurrently can be used safely.
"""
from uuid import uuid4
from django.contrib.postgres.operations import RemoveIndexConcurrently
from django.db import migrations, models
def drop_resource_scan_summary_resource_id_index(apps, schema_editor):
with schema_editor.connection.cursor() as cursor:
cursor.execute(
"""
SELECT idx_ns.nspname, idx.relname
FROM pg_class tbl
JOIN pg_namespace tbl_ns ON tbl_ns.oid = tbl.relnamespace
JOIN pg_index i ON i.indrelid = tbl.oid
JOIN pg_class idx ON idx.oid = i.indexrelid
JOIN pg_namespace idx_ns ON idx_ns.oid = idx.relnamespace
JOIN pg_attribute a
ON a.attrelid = tbl.oid
AND a.attnum = (i.indkey::int[])[0]
WHERE tbl_ns.nspname = ANY (current_schemas(false))
AND tbl.relname = %s
AND i.indnatts = 1
AND a.attname = %s
""",
["resource_scan_summaries", "resource_id"],
)
row = cursor.fetchone()
if not row:
return
schema_name, index_name = row
quote_name = schema_editor.connection.ops.quote_name
qualified_name = f"{quote_name(schema_name)}.{quote_name(index_name)}"
schema_editor.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified_name};")
class Migration(migrations.Migration):
atomic = False
dependencies = [
("api", "0071_drop_partitioned_indexes"),
]
operations = [
RemoveIndexConcurrently(
model_name="resource",
name="gin_resources_search_idx",
),
RemoveIndexConcurrently(
model_name="resourcetag",
name="gin_resource_tags_search_idx",
),
RemoveIndexConcurrently(
model_name="scansummary",
name="ss_tenant_scan_service_idx",
),
RemoveIndexConcurrently(
model_name="complianceoverview",
name="comp_ov_cp_id_idx",
),
RemoveIndexConcurrently(
model_name="complianceoverview",
name="comp_ov_req_fail_idx",
),
RemoveIndexConcurrently(
model_name="complianceoverview",
name="comp_ov_cp_id_req_fail_idx",
),
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunPython(
drop_resource_scan_summary_resource_id_index,
reverse_code=migrations.RunPython.noop,
),
],
state_operations=[
migrations.AlterField(
model_name="resourcescansummary",
name="resource_id",
field=models.UUIDField(default=uuid4),
),
],
),
]
@@ -0,0 +1,31 @@
from functools import partial
from django.db import migrations
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
class Migration(migrations.Migration):
atomic = False
dependencies = [
("api", "0072_drop_unused_indexes"),
]
operations = [
migrations.RunPython(
partial(
create_index_on_partitions,
parent_table="findings",
index_name="find_tenant_scan_fail_new_idx",
columns="tenant_id, scan_id",
where="status = 'FAIL' AND delta = 'new'",
all_partitions=True,
),
reverse_code=partial(
drop_index_on_partitions,
parent_table="findings",
index_name="find_tenant_scan_fail_new_idx",
),
)
]
@@ -0,0 +1,54 @@
from django.db import migrations, models
INDEX_NAME = "find_tenant_scan_fail_new_idx"
PARENT_TABLE = "findings"
def create_parent_and_attach(apps, schema_editor):
with schema_editor.connection.cursor() as cursor:
cursor.execute(
f"CREATE INDEX {INDEX_NAME} ON ONLY {PARENT_TABLE} "
f"USING btree (tenant_id, scan_id) "
f"WHERE status = 'FAIL' AND delta = 'new'"
)
cursor.execute(
"SELECT inhrelid::regclass::text "
"FROM pg_inherits "
"WHERE inhparent = %s::regclass",
[PARENT_TABLE],
)
for (partition,) in cursor.fetchall():
child_idx = f"{partition.replace('.', '_')}_{INDEX_NAME}"
cursor.execute(f"ALTER INDEX {INDEX_NAME} ATTACH PARTITION {child_idx}")
def drop_parent_index(apps, schema_editor):
with schema_editor.connection.cursor() as cursor:
cursor.execute(f"DROP INDEX IF EXISTS {INDEX_NAME}")
class Migration(migrations.Migration):
dependencies = [
("api", "0073_findings_fail_new_index_partitions"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AddIndex(
model_name="finding",
index=models.Index(
condition=models.Q(status="FAIL", delta="new"),
fields=["tenant_id", "scan_id"],
name=INDEX_NAME,
),
),
],
database_operations=[
migrations.RunPython(
create_parent_and_attach,
reverse_code=drop_parent_index,
),
],
),
]
@@ -0,0 +1,38 @@
# Generated by Django migration for Cloudflare provider support
from django.db import migrations
import api.db_utils
class Migration(migrations.Migration):
dependencies = [
("api", "0074_findings_fail_new_index_parent"),
]
operations = [
migrations.AlterField(
model_name="provider",
name="provider",
field=api.db_utils.ProviderEnumField(
choices=[
("aws", "AWS"),
("azure", "Azure"),
("gcp", "GCP"),
("kubernetes", "Kubernetes"),
("m365", "M365"),
("github", "GitHub"),
("mongodbatlas", "MongoDB Atlas"),
("iac", "IaC"),
("oraclecloud", "Oracle Cloud Infrastructure"),
("alibabacloud", "Alibaba Cloud"),
("cloudflare", "Cloudflare"),
],
default="aws",
),
),
migrations.RunSQL(
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'cloudflare';",
reverse_sql=migrations.RunSQL.noop,
),
]
@@ -0,0 +1,39 @@
# Generated by Django migration for Image provider support
from django.db import migrations
import api.db_utils
class Migration(migrations.Migration):
dependencies = [
("api", "0075_cloudflare_provider"),
]
operations = [
migrations.AlterField(
model_name="provider",
name="provider",
field=api.db_utils.ProviderEnumField(
choices=[
("aws", "AWS"),
("azure", "Azure"),
("gcp", "GCP"),
("kubernetes", "Kubernetes"),
("m365", "M365"),
("github", "GitHub"),
("mongodbatlas", "MongoDB Atlas"),
("iac", "IaC"),
("oraclecloud", "Oracle Cloud Infrastructure"),
("alibabacloud", "Alibaba Cloud"),
("cloudflare", "Cloudflare"),
("image", "Image"),
],
default="aws",
),
),
migrations.RunSQL(
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'image';",
reverse_sql=migrations.RunSQL.noop,
),
]
+37 -47
View File
@@ -12,7 +12,6 @@ from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
@@ -288,6 +287,8 @@ class Provider(RowLevelSecurityProtectedModel):
IAC = "iac", _("IaC")
ORACLECLOUD = "oraclecloud", _("Oracle Cloud Infrastructure")
ALIBABACLOUD = "alibabacloud", _("Alibaba Cloud")
CLOUDFLARE = "cloudflare", _("Cloudflare")
IMAGE = "image", _("Image")
@staticmethod
def validate_aws_uid(value):
@@ -401,6 +402,35 @@ class Provider(RowLevelSecurityProtectedModel):
pointer="/data/attributes/uid",
)
@staticmethod
def validate_cloudflare_uid(value):
if not re.match(r"^[a-f0-9]{32}$", value):
raise ModelValidationError(
detail="Cloudflare Account ID must be a 32-character hexadecimal string.",
code="cloudflare-uid",
pointer="/data/attributes/uid",
)
@staticmethod
def validate_image_uid(value):
pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:\d{1,5})?(/[a-zA-Z0-9._-]+)*/?$"
if not re.match(pattern, value):
raise ModelValidationError(
detail="Image provider ID must be a valid registry URL "
"(e.g., docker.io, ghcr.io, registry.example.com:5000).",
code="image-uid",
pointer="/data/attributes/uid",
)
port_match = re.search(r":(\d{1,5})(?=/|$)", value)
if port_match:
port = int(port_match.group(1))
if not 1 <= port <= 65535:
raise ModelValidationError(
detail="Port number must be between 1 and 65535.",
code="image-uid",
pointer="/data/attributes/uid",
)
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
@@ -424,6 +454,8 @@ class Provider(RowLevelSecurityProtectedModel):
def clean(self):
super().clean()
if self.provider == self.ProviderChoices.IMAGE.value and self.uid:
self.uid = re.sub(r"^https?://", "", self.uid)
getattr(self, f"validate_{self.provider}_uid")(self.uid)
def save(self, *args, **kwargs):
@@ -672,8 +704,6 @@ class AttackPathsScan(RowLevelSecurityProtectedModel):
update_tag = models.BigIntegerField(
null=True, blank=True, help_text="Cartography update tag (epoch)"
)
graph_database = models.CharField(max_length=63, null=True, blank=True)
is_graph_database_deleted = models.BooleanField(default=False)
ingestion_exceptions = models.JSONField(default=dict, null=True, blank=True)
class Meta(RowLevelSecurityProtectedModel.Meta):
@@ -700,21 +730,6 @@ class AttackPathsScan(RowLevelSecurityProtectedModel):
fields=["tenant_id", "scan_id"],
name="aps_scan_lookup_idx",
),
models.Index(
fields=["tenant_id", "provider_id"],
name="aps_active_graph_idx",
include=["graph_database", "id"],
condition=Q(is_graph_database_deleted=False),
),
models.Index(
fields=["tenant_id", "provider_id", "-completed_at"],
name="aps_completed_graph_idx",
include=["graph_database", "id"],
condition=Q(
state=StateChoices.COMPLETED,
is_graph_database_deleted=False,
),
),
]
class JSONAPIMeta:
@@ -741,10 +756,6 @@ class ResourceTag(RowLevelSecurityProtectedModel):
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "resource_tags"
indexes = [
GinIndex(fields=["text_search"], name="gin_resource_tags_search_idx"),
]
constraints = [
models.UniqueConstraint(
fields=("tenant_id", "key", "value"),
@@ -853,7 +864,6 @@ class Resource(RowLevelSecurityProtectedModel):
fields=["tenant_id", "service", "region", "type"],
name="resource_tenant_metadata_idx",
),
GinIndex(fields=["text_search"], name="gin_resources_search_idx"),
models.Index(fields=["tenant_id", "id"], name="resources_tenant_id_idx"),
models.Index(
fields=["tenant_id", "provider_id"],
@@ -1038,23 +1048,19 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
indexes = [
models.Index(fields=["tenant_id", "id"], name="findings_tenant_and_id_idx"),
GinIndex(fields=["text_search"], name="gin_findings_search_idx"),
models.Index(fields=["tenant_id", "scan_id"], name="find_tenant_scan_idx"),
models.Index(
fields=["tenant_id", "scan_id", "id"], name="find_tenant_scan_id_idx"
),
models.Index(
fields=["tenant_id", "id"],
condition=Q(delta="new"),
name="find_delta_new_idx",
condition=models.Q(status=StatusChoices.FAIL, delta="new"),
fields=["tenant_id", "scan_id"],
name="find_tenant_scan_fail_new_idx",
),
models.Index(
fields=["tenant_id", "uid", "-inserted_at"],
name="find_tenant_uid_inserted_idx",
),
GinIndex(fields=["resource_services"], name="gin_find_service_idx"),
GinIndex(fields=["resource_regions"], name="gin_find_region_idx"),
GinIndex(fields=["resource_types"], name="gin_find_rtype_idx"),
models.Index(
fields=["tenant_id", "scan_id", "check_id"],
name="find_tenant_scan_check_idx",
@@ -1122,10 +1128,6 @@ class ResourceFindingMapping(PostgresPartitionedModel, RowLevelSecurityProtected
# - id
indexes = [
models.Index(
fields=["tenant_id", "finding_id"],
name="rfm_tenant_finding_idx",
),
models.Index(
fields=["tenant_id", "resource_id"],
name="rfm_tenant_resource_idx",
@@ -1442,14 +1444,6 @@ class ComplianceOverview(RowLevelSecurityProtectedModel):
statements=["SELECT", "INSERT", "DELETE"],
),
]
indexes = [
models.Index(fields=["compliance_id"], name="comp_ov_cp_id_idx"),
models.Index(fields=["requirements_failed"], name="comp_ov_req_fail_idx"),
models.Index(
fields=["compliance_id", "requirements_failed"],
name="comp_ov_cp_id_req_fail_idx",
),
]
class JSONAPIMeta:
resource_name = "compliance-overviews"
@@ -1615,10 +1609,6 @@ class ScanSummary(RowLevelSecurityProtectedModel):
fields=["tenant_id", "scan_id"],
name="scan_summaries_tenant_scan_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "service"],
name="ss_tenant_scan_service_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "severity"],
name="ss_tenant_scan_severity_idx",
@@ -2033,7 +2023,7 @@ class SAMLConfiguration(RowLevelSecurityProtectedModel):
class ResourceScanSummary(RowLevelSecurityProtectedModel):
scan_id = models.UUIDField(default=uuid7, db_index=True)
resource_id = models.UUIDField(default=uuid4, db_index=True)
resource_id = models.UUIDField(default=uuid4)
service = models.CharField(max_length=100)
region = models.CharField(max_length=100)
resource_type = models.CharField(max_length=100)
File diff suppressed because it is too large. Load diff
+153
View File
@@ -20,9 +20,11 @@ from prowler.providers.alibabacloud.alibabacloud_provider import AlibabacloudPro
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.github.github_provider import GithubProvider
from prowler.providers.iac.iac_provider import IacProvider
from prowler.providers.image.image_provider import ImageProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider
@@ -118,6 +120,8 @@ class TestReturnProwlerProvider:
(Provider.ProviderChoices.ORACLECLOUD.value, OraclecloudProvider),
(Provider.ProviderChoices.IAC.value, IacProvider),
(Provider.ProviderChoices.ALIBABACLOUD.value, AlibabacloudProvider),
(Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider),
(Provider.ProviderChoices.IMAGE.value, ImageProvider),
],
)
def test_return_prowler_provider(self, provider_type, expected_provider):
@@ -184,6 +188,54 @@ class TestProwlerProviderConnectionTest:
assert isinstance(connection.error, Provider.secret.RelatedObjectDoesNotExist)
assert str(connection.error) == "Provider has no secret."
@patch(
"prowler.providers.image.lib.registry.factory.create_registry_adapter",
)
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_image_success(
self, mock_return_prowler_provider, mock_create_adapter
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.IMAGE.value
provider.uid = "ghcr.io"
provider.secret.secret = {"registry_token": "tok"}
mock_adapter = MagicMock()
mock_create_adapter.return_value = mock_adapter
connection = prowler_provider_connection_test(provider)
assert connection.is_connected is True
assert connection.error is None
mock_create_adapter.assert_called_once_with(
registry_url="ghcr.io",
username=None,
password=None,
token="tok",
)
mock_adapter.list_repositories.assert_called_once()
@patch(
"prowler.providers.image.lib.registry.factory.create_registry_adapter",
)
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_image_failure(
self, mock_return_prowler_provider, mock_create_adapter
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.IMAGE.value
provider.uid = "ghcr.io"
provider.secret.secret = {"registry_token": "bad-token"}
mock_adapter = MagicMock()
mock_adapter.list_repositories.side_effect = Exception("401 Unauthorized")
mock_create_adapter.return_value = mock_adapter
connection = prowler_provider_connection_test(provider)
assert connection.is_connected is False
assert connection.error == "401 Unauthorized"
class TestGetProwlerProviderKwargs:
@pytest.mark.parametrize(
@@ -221,6 +273,10 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.MONGODBATLAS.value,
{"atlas_organization_id": "provider_uid"},
),
(
Provider.ProviderChoices.CLOUDFLARE.value,
{"filter_accounts": ["provider_uid"]},
),
],
)
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
@@ -324,6 +380,103 @@ class TestGetProwlerProviderKwargs:
}
assert result == expected_result
def test_get_prowler_provider_kwargs_image_provider(self):
"""Test that Image provider gets correct kwargs with registry URL and auth."""
provider_uid = "ghcr.io"
secret_dict = {
"registry_username": "user",
"registry_password": "pass",
}
secret_mock = MagicMock()
secret_mock.secret = secret_dict
provider = MagicMock()
provider.provider = Provider.ProviderChoices.IMAGE.value
provider.secret = secret_mock
provider.uid = provider_uid
result = get_prowler_provider_kwargs(provider)
expected_result = {
"registry": provider_uid,
"registry_username": "user",
"registry_password": "pass",
}
assert result == expected_result
def test_get_prowler_provider_kwargs_image_provider_with_filters(self):
"""Test that Image provider includes scan filters."""
provider_uid = "docker.io"
secret_dict = {
"registry_token": "ghp_abc123",
"image_filter": "my-app.*",
"tag_filter": "v[0-9]+",
"max_images": 50,
}
secret_mock = MagicMock()
secret_mock.secret = secret_dict
provider = MagicMock()
provider.provider = Provider.ProviderChoices.IMAGE.value
provider.secret = secret_mock
provider.uid = provider_uid
result = get_prowler_provider_kwargs(provider)
expected_result = {
"registry": provider_uid,
"registry_token": "ghp_abc123",
"image_filter": "my-app.*",
"tag_filter": "v[0-9]+",
"max_images": 50,
}
assert result == expected_result
def test_get_prowler_provider_kwargs_image_provider_no_auth(self):
"""Test that Image provider works with empty secret for public registries."""
provider_uid = "docker.io"
secret_dict = {}
secret_mock = MagicMock()
secret_mock.secret = secret_dict
provider = MagicMock()
provider.provider = Provider.ProviderChoices.IMAGE.value
provider.secret = secret_mock
provider.uid = provider_uid
result = get_prowler_provider_kwargs(provider)
expected_result = {"registry": provider_uid}
assert result == expected_result
def test_get_prowler_provider_kwargs_image_provider_ignores_mutelist(self):
"""Test that Image provider does NOT receive mutelist_content.
Image provider uses Trivy's built-in logic, so it should not
receive mutelist_content even when a mutelist processor is configured.
"""
provider_uid = "ghcr.io"
secret_dict = {"registry_token": "test_token"}
secret_mock = MagicMock()
secret_mock.secret = secret_dict
mutelist_processor = MagicMock()
mutelist_processor.configuration = {"Mutelist": {"key": "value"}}
provider = MagicMock()
provider.provider = Provider.ProviderChoices.IMAGE.value
provider.secret = secret_mock
provider.uid = provider_uid
result = get_prowler_provider_kwargs(provider, mutelist_processor)
assert "mutelist_content" not in result
expected_result = {
"registry": provider_uid,
"registry_token": "test_token",
}
assert result == expected_result
def test_get_prowler_provider_kwargs_unsupported_provider(self):
# Setup
provider_uid = "provider_uid"
+532 -70
View File
@@ -1174,6 +1174,31 @@ class TestProviderViewSet:
"uid": "1234567890123456",
"alias": "Alibaba Cloud Account",
},
{
"provider": "cloudflare",
"uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"alias": "Cloudflare Account",
},
{
"provider": "image",
"uid": "ghcr.io",
"alias": "GitHub Registry",
},
{
"provider": "image",
"uid": "docker.io",
"alias": "Docker Hub",
},
{
"provider": "image",
"uid": "123456789012.dkr.ecr.us-east-1.amazonaws.com",
"alias": "ECR",
},
{
"provider": "image",
"uid": "https://registry.example.com:5000",
"alias": "Private",
},
]
),
)
@@ -1553,6 +1578,66 @@ class TestProviderViewSet:
"alibabacloud-uid",
"uid",
),
# Cloudflare UID validation - too short (not 32 hex chars)
(
{
"provider": "cloudflare",
"uid": "abc123",
"alias": "test",
},
"cloudflare-uid",
"uid",
),
# Cloudflare UID validation - uppercase hex (must be lowercase)
(
{
"provider": "cloudflare",
"uid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
"alias": "test",
},
"cloudflare-uid",
"uid",
),
# Cloudflare UID validation - non-hex characters
(
{
"provider": "cloudflare",
"uid": "g1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"alias": "test",
},
"cloudflare-uid",
"uid",
),
# Cloudflare UID validation - too long (33 chars)
(
{
"provider": "cloudflare",
"uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e",
"alias": "test",
},
"cloudflare-uid",
"uid",
),
# Image UID validation - too short (below min_length)
(
{
"provider": "image",
"uid": "ab",
"alias": "test",
},
"min_length",
"uid",
),
# Image UID validation - invalid characters (space)
(
{
"provider": "image",
"uid": "not valid!",
"alias": "test",
},
"image-uid",
"uid",
),
]
),
)
@@ -1708,6 +1793,38 @@ class TestProviderViewSet:
assert "Content-Location" in response.headers
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.check_provider_connection_task.delay")
def test_providers_connection_image(
self,
mock_provider_connection,
mock_task_get,
authenticated_client,
providers_fixture,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
task_mock = Mock()
task_mock.id = prowler_task.id
task_mock.status = "PENDING"
mock_provider_connection.return_value = task_mock
mock_task_get.return_value = prowler_task
image_provider = providers_fixture[10]
assert image_provider.provider == "image"
assert image_provider.connected is None
assert image_provider.connection_last_checked_at is None
response = authenticated_client.post(
reverse("provider-connection", kwargs={"pk": image_provider.id})
)
assert response.status_code == status.HTTP_202_ACCEPTED
mock_provider_connection.assert_called_once_with(
provider_id=str(image_provider.id), tenant_id=ANY
)
assert "Content-Location" in response.headers
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
def test_providers_connection_invalid_provider(
self, authenticated_client, providers_fixture
):
@@ -1726,21 +1843,21 @@ class TestProviderViewSet:
(
"uid.icontains",
"1",
8,
9,
),
("alias", "aws_testing_1", 1),
("alias.icontains", "aws", 2),
("inserted_at", TODAY, 9),
("inserted_at", TODAY, 11),
(
"inserted_at.gte",
"2024-01-01",
9,
11,
),
("inserted_at.lte", "2024-01-01", 0),
(
"updated_at.gte",
"2024-01-01",
9,
11,
),
("updated_at.lte", "2024-01-01", 0),
]
@@ -2330,6 +2447,56 @@ class TestProviderSecretViewSet:
"role_session_name": "ProwlerAuditSession",
},
),
# Cloudflare with API Token
(
Provider.ProviderChoices.CLOUDFLARE.value,
ProviderSecret.TypeChoices.STATIC,
{
"api_token": "fake-cloudflare-api-token-for-testing",
},
),
# Cloudflare with API Key + Email
(
Provider.ProviderChoices.CLOUDFLARE.value,
ProviderSecret.TypeChoices.STATIC,
{
"api_key": "fake-cloudflare-api-key-for-testing",
"api_email": "user@example.com",
},
),
# Image with Docker login
(
Provider.ProviderChoices.IMAGE.value,
ProviderSecret.TypeChoices.STATIC,
{
"registry_username": "user",
"registry_password": "pass",
},
),
# Image with token
(
Provider.ProviderChoices.IMAGE.value,
ProviderSecret.TypeChoices.STATIC,
{
"registry_token": "ghp_abc123",
},
),
# Image with no auth + filters
(
Provider.ProviderChoices.IMAGE.value,
ProviderSecret.TypeChoices.STATIC,
{
"image_filter": "my-app.*",
"tag_filter": "v[0-9]+",
"max_images": 50,
},
),
# Image with empty secret (public registry)
(
Provider.ProviderChoices.IMAGE.value,
ProviderSecret.TypeChoices.STATIC,
{},
),
],
)
def test_provider_secrets_create_valid(
@@ -3768,6 +3935,7 @@ class TestAttackPathsScanViewSet:
AttackPathsQueryDefinition(
id="aws-rds",
name="RDS inventory",
short_description="List account RDS assets.",
description="List account RDS assets",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
@@ -3825,11 +3993,11 @@ class TestAttackPathsScanViewSet:
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
graph_database="tenant-db",
)
query_definition = AttackPathsQueryDefinition(
id="aws-rds",
name="RDS inventory",
short_description="List account RDS assets.",
description="List account RDS assets",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
@@ -3855,10 +4023,16 @@ class TestAttackPathsScanViewSet:
],
}
expected_db_name = f"db-tenant-{attack_paths_scan.provider.tenant_id}"
with (
patch(
"api.v1.views.get_query_by_id", return_value=query_definition
) as mock_get_query,
patch(
"api.v1.views.graph_database.get_database_name",
return_value=expected_db_name,
) as mock_get_db_name,
patch(
"api.v1.views.attack_paths_views_helpers.prepare_query_parameters",
return_value=prepared_parameters,
@@ -3880,17 +4054,18 @@ class TestAttackPathsScanViewSet:
assert response.status_code == status.HTTP_200_OK
mock_get_query.assert_called_once_with("aws-rds")
mock_get_db_name.assert_called_once_with(attack_paths_scan.provider.tenant_id)
mock_prepare.assert_called_once_with(
query_definition,
{},
attack_paths_scan.provider.uid,
)
mock_execute.assert_called_once_with(
attack_paths_scan,
expected_db_name,
query_definition,
prepared_parameters,
)
mock_clear_cache.assert_called_once_with(attack_paths_scan.graph_database)
mock_clear_cache.assert_called_once_with(expected_db_name)
result = response.json()["data"]
attributes = result["attributes"]
assert attributes["nodes"] == graph_payload["nodes"]
@@ -3921,31 +4096,6 @@ class TestAttackPathsScanViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "must be completed" in response.json()["errors"][0]["detail"]
def test_run_attack_paths_query_requires_graph_database(
self,
authenticated_client,
providers_fixture,
scans_fixture,
create_attack_paths_scan,
):
provider = providers_fixture[0]
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
graph_database=None,
)
response = authenticated_client.post(
reverse(
"attack-paths-scans-queries-run", kwargs={"pk": attack_paths_scan.id}
),
data=self._run_payload(),
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "does not reference a graph database" in str(response.json())
def test_run_attack_paths_query_unknown_query(
self,
authenticated_client,
@@ -3987,6 +4137,7 @@ class TestAttackPathsScanViewSet:
query_definition = AttackPathsQueryDefinition(
id="aws-empty",
name="empty",
short_description="",
description="",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
@@ -4356,6 +4507,108 @@ class TestResourceViewSet:
assert attributes["types"] == [latest_scan_resource.type]
assert "groups" in attributes
def test_resources_latest_filter_by_provider_id(
self, authenticated_client, latest_scan_resource
):
"""Test that provider_id filter works on latest resources endpoint."""
provider = latest_scan_resource.provider
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id]": str(provider.id)},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
assert (
response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
)
def test_resources_latest_filter_by_provider_id_in(
self, authenticated_client, latest_scan_resource
):
"""Test that provider_id__in filter works on latest resources endpoint."""
provider = latest_scan_resource.provider
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id__in]": str(provider.id)},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
assert (
response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
)
def test_resources_latest_filter_by_provider_id_in_multiple(
self, authenticated_client, providers_fixture
):
"""Test that provider_id__in filter works with multiple provider IDs."""
provider1, provider2 = providers_fixture[0], providers_fixture[1]
tenant_id = str(provider1.tenant_id)
# Create completed scans for both providers
Scan.objects.create(
name="scan for provider 1",
provider=provider1,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant_id,
)
Scan.objects.create(
name="scan for provider 2",
provider=provider2,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant_id,
)
# Create resources for each provider
resource1 = Resource.objects.create(
tenant_id=tenant_id,
provider=provider1,
uid="resource_provider_1",
name="Resource Provider 1",
region="us-east-1",
service="ec2",
type="instance",
)
Resource.objects.create(
tenant_id=tenant_id,
provider=provider2,
uid="resource_provider_2",
name="Resource Provider 2",
region="us-west-2",
service="s3",
type="bucket",
)
# Test filtering by both providers
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id__in]": f"{provider1.id},{provider2.id}"},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 2
# Test filtering by single provider returns only that provider's resource
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id__in]": str(provider1.id)},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid
def test_resources_latest_filter_by_provider_id_no_match(
self, authenticated_client, latest_scan_resource
):
"""Test that provider_id filter returns empty when no match."""
non_existent_id = str(uuid4())
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id]": non_existent_id},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 0
# Events endpoint tests
def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
"""Test events endpoint rejects non-AWS providers."""
@@ -4851,6 +5104,68 @@ class TestResourceViewSet:
assert error["status"] == "503" # Must be string per JSON:API spec
assert "detail" in error
@patch("api.v1.views.initialize_prowler_provider")
def test_events_assume_role_access_denied(
self,
mock_initialize_provider,
authenticated_client,
providers_fixture,
):
"""Test events handles AWSAssumeRoleError during provider init.
This tests the scenario from CLOUD-API-3HJ where the API task role
cannot assume the customer's ProwlerScan role due to IAM permissions.
The error happens during initialize_prowler_provider, which wraps
the ClientError in AWSAssumeRoleError.
"""
from api.models import Resource
from prowler.providers.aws.exceptions.exceptions import AWSAssumeRoleError
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:lambda:eu-west-1:123456789012:function:assume-role-test",
name="AssumeRole Test Function",
type="function",
region="eu-west-1",
service="lambda",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
# Mock initialize_prowler_provider raising AWSAssumeRoleError
# (this is what aws_provider.py actually raises when AssumeRole fails)
original_error = ClientError(
{
"Error": {
"Code": "AccessDenied",
"Message": (
"User: arn:aws:sts::123456789012:assumed-role/api-task-role/xxx "
"is not authorized to perform: sts:AssumeRole on resource: "
"arn:aws:iam::123456789012:role/ProwlerScan"
),
}
},
"AssumeRole",
)
mock_initialize_provider.side_effect = AWSAssumeRoleError(
original_exception=original_error,
file="aws_provider.py",
)
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id})
)
# AWSAssumeRoleError returns 502 (upstream auth failure)
assert response.status_code == status.HTTP_502_BAD_GATEWAY
# Verify JSON:API error structure
error = response.json()["errors"][0]
assert error["code"] == "upstream_access_denied"
assert error["status"] == "502"
assert "detail" in error
def test_events_unauthenticated_returns_401(self, providers_fixture):
"""Test events endpoint returns 401 when no credentials are provided.
@@ -10615,25 +10930,20 @@ class TestTenantFinishACSView:
assert "sso_saml_failed=true" in response.url
def test_dispatch_skips_role_mapping_when_single_manage_account_user(
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
# Create a single role with manage_account=True for the user
admin_role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant=tenant,
manage_account=True,
manage_users=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
admin_role = admin_role_fixture
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=admin_role, tenant_id=tenant.id
)
@@ -10704,35 +11014,26 @@ class TestTenantFinishACSView:
.exists()
)
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_existing_role(
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
roles_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
"""Test that role mapping is skipped when it would remove the last MANAGE_ACCOUNT user"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
# Create a second user with manage_account=True
second_admin = User.objects.using(MainRouter.admin_db).create(
email="admin2@prowler.com", name="Second Admin"
)
admin_role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant=tenant,
manage_account=True,
manage_users=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
admin_role = admin_role_fixture
viewer_role = roles_fixture[3]
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=admin_role, tenant_id=tenant.id
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=second_admin, role=admin_role, tenant_id=tenant.id
)
social_account = SocialAccount(
user=user,
@@ -10741,7 +11042,7 @@ class TestTenantFinishACSView:
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
"userType": ["viewer"], # This SHOULD be applied
"userType": [viewer_role.name],
},
)
@@ -10779,10 +11080,91 @@ class TestTenantFinishACSView:
assert response.status_code == 302
# Verify the viewer role was created and assigned (role mapping was applied)
viewer_role = Role.objects.using(MainRouter.admin_db).get(
name="viewer", tenant=tenant
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=admin_role, tenant_id=tenant.id)
.exists()
)
assert not (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
.exists()
)
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
roles_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
# Create a second user with manage_account=True
second_admin = User.objects.using(MainRouter.admin_db).create(
email="admin2@prowler.com", name="Second Admin"
)
admin_role = admin_role_fixture
viewer_role = roles_fixture[3]
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=admin_role, tenant_id=tenant.id
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=second_admin, role=admin_role, tenant_id=tenant.id
)
social_account = SocialAccount(
user=user,
provider="saml",
extra_data={
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
"userType": [viewer_role.name], # This SHOULD be applied
},
)
request = RequestFactory().get(
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
)
request.user = user
request.session = {}
with (
patch(
"allauth.socialaccount.providers.saml.views.get_app_or_404"
) as mock_get_app_or_404,
patch(
"allauth.socialaccount.models.SocialApp.objects.get"
) as mock_socialapp_get,
patch(
"allauth.socialaccount.models.SocialAccount.objects.get"
) as mock_sa_get,
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
provider="saml", client_id="testtenant", name="Test App", settings={}
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
mock_saml_config_get.return_value = MagicMock()
mock_user_get.return_value = user
view = TenantFinishACSView.as_view()
response = view(request, organization_slug="testtenant")
assert response.status_code == 302
# Verify the viewer role was assigned (role mapping was applied)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
@@ -10796,6 +11178,86 @@ class TestTenantFinishACSView:
.exists()
)
def test_dispatch_applies_role_mapping_for_non_admin_user_with_single_admin(
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
roles_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that role mapping is applied for a non-admin user when a single admin exists"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
admin_user = create_test_user
tenant = tenants_fixture[0]
non_admin_user = User.objects.using(MainRouter.admin_db).create(
email="viewer@prowler.com", name="Viewer"
)
admin_role = admin_role_fixture
viewer_role = roles_fixture[3]
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=admin_user, role=admin_role, tenant_id=tenant.id
)
social_account = SocialAccount(
user=non_admin_user,
provider="saml",
extra_data={
"firstName": ["Jane"],
"lastName": ["Doe"],
"organization": ["testing_company"],
"userType": [viewer_role.name],
},
)
request = RequestFactory().get(
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
)
request.user = non_admin_user
request.session = {}
with (
patch(
"allauth.socialaccount.providers.saml.views.get_app_or_404"
) as mock_get_app_or_404,
patch(
"allauth.socialaccount.models.SocialApp.objects.get"
) as mock_socialapp_get,
patch(
"allauth.socialaccount.models.SocialAccount.objects.get"
) as mock_sa_get,
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
provider="saml", client_id="testtenant", name="Test App", settings={}
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
mock_saml_config_get.return_value = MagicMock()
mock_user_get.return_value = non_admin_user
view = TenantFinishACSView.as_view()
response = view(request, organization_slug="testtenant")
assert response.status_code == 302
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=non_admin_user, role=viewer_role, tenant_id=tenant.id)
.exists()
)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=admin_user, role=admin_role, tenant_id=tenant.id)
.exists()
)
@pytest.mark.django_db
class TestLighthouseConfigViewSet:
+58 -4
View File
@@ -24,9 +24,11 @@ if TYPE_CHECKING:
)
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.github.github_provider import GithubProvider
from prowler.providers.iac.iac_provider import IacProvider
from prowler.providers.image.image_provider import ImageProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.mongodbatlas.mongodbatlas_provider import (
@@ -77,9 +79,11 @@ def return_prowler_provider(
AlibabacloudProvider
| AwsProvider
| AzureProvider
| CloudflareProvider
| GcpProvider
| GithubProvider
| IacProvider
| ImageProvider
| KubernetesProvider
| M365Provider
| MongodbatlasProvider
@@ -91,7 +95,7 @@ def return_prowler_provider(
provider (Provider): The provider object containing the provider type and associated secrets.
Returns:
AlibabacloudProvider | AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class.
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class.
Raises:
ValueError: If the provider type specified in `provider.provider` is not supported.
@@ -145,6 +149,16 @@ def return_prowler_provider(
)
prowler_provider = AlibabacloudProvider
case Provider.ProviderChoices.CLOUDFLARE.value:
from prowler.providers.cloudflare.cloudflare_provider import (
CloudflareProvider,
)
prowler_provider = CloudflareProvider
case Provider.ProviderChoices.IMAGE.value:
from prowler.providers.image.image_provider import ImageProvider
prowler_provider = ImageProvider
case _:
raise ValueError(f"Provider type {provider.provider} not supported")
return prowler_provider
@@ -196,11 +210,35 @@ def get_prowler_provider_kwargs(
**prowler_provider_kwargs,
"atlas_organization_id": provider.uid,
}
elif provider.provider == Provider.ProviderChoices.CLOUDFLARE.value:
prowler_provider_kwargs = {
**prowler_provider_kwargs,
"filter_accounts": [provider.uid],
}
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
prowler_provider_kwargs = {
"registry": provider.uid,
}
secret = provider.secret.secret
for key in (
"registry_username",
"registry_password",
"registry_token",
"image_filter",
"tag_filter",
):
if key in secret:
prowler_provider_kwargs[key] = secret[key]
if "max_images" in secret:
prowler_provider_kwargs["max_images"] = int(secret["max_images"])
if mutelist_processor:
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
# IaC provider doesn't support mutelist (uses Trivy's built-in logic)
if mutelist_content and provider.provider != Provider.ProviderChoices.IAC.value:
# IaC and Image providers don't support mutelist (Trivy handles its own logic)
if mutelist_content and provider.provider not in (
Provider.ProviderChoices.IAC.value,
Provider.ProviderChoices.IMAGE.value,
):
prowler_provider_kwargs["mutelist_content"] = mutelist_content
return prowler_provider_kwargs
@@ -213,9 +251,11 @@ def initialize_prowler_provider(
AlibabacloudProvider
| AwsProvider
| AzureProvider
| CloudflareProvider
| GcpProvider
| GithubProvider
| IacProvider
| ImageProvider
| KubernetesProvider
| M365Provider
| MongodbatlasProvider
@@ -228,7 +268,7 @@ def initialize_prowler_provider(
mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration.
Returns:
AlibabacloudProvider | AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class
initialized with the provider's secrets.
"""
prowler_provider = return_prowler_provider(provider)
@@ -263,6 +303,20 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
if "access_token" in prowler_provider_kwargs:
iac_test_kwargs["access_token"] = prowler_provider_kwargs["access_token"]
return prowler_provider.test_connection(**iac_test_kwargs)
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
from prowler.providers.image.lib.registry.factory import create_registry_adapter
try:
adapter = create_registry_adapter(
registry_url=provider.uid,
username=prowler_provider_kwargs.get("registry_username"),
password=prowler_provider_kwargs.get("registry_password"),
token=prowler_provider_kwargs.get("registry_token"),
)
adapter.list_repositories()
return Connection(is_connected=True, error=None)
except Exception as e:
return Connection(is_connected=False, error=str(e))
else:
return prowler_provider.test_connection(
**prowler_provider_kwargs,
@@ -346,6 +346,64 @@ from rest_framework_json_api import serializers
},
"required": ["role_arn", "access_key_id", "access_key_secret"],
},
{
"type": "object",
"title": "Cloudflare API Token",
"properties": {
"api_token": {
"type": "string",
"description": "Cloudflare API Token for authentication (recommended).",
},
},
"required": ["api_token"],
},
{
"type": "object",
"title": "Cloudflare API Key + Email",
"properties": {
"api_key": {
"type": "string",
"description": "Cloudflare Global API Key for authentication (legacy).",
},
"api_email": {
"type": "string",
"format": "email",
"description": "Email address associated with the Cloudflare account.",
},
},
"required": ["api_key", "api_email"],
},
{
"type": "object",
"title": "Image Registry Credentials",
"properties": {
"registry_username": {
"type": "string",
"description": "Username for Docker login authentication.",
},
"registry_password": {
"type": "string",
"description": "Password for Docker login authentication.",
},
"registry_token": {
"type": "string",
"description": "Bearer token for registry authentication.",
},
"image_filter": {
"type": "string",
"description": "Regex pattern to filter repository names during registry enumeration.",
},
"tag_filter": {
"type": "string",
"description": "Regex pattern to filter image tags during registry enumeration.",
},
"max_images": {
"type": "integer",
"minimum": 0,
"description": "Maximum number of images to scan (0 = unlimited).",
},
},
},
]
}
)
+70
View File
@@ -1176,6 +1176,14 @@ class AttackPathsScanSerializer(RLSSerializer):
return provider.uid if provider else None
class AttackPathsQueryAttributionSerializer(BaseSerializerV1):
text = serializers.CharField()
link = serializers.CharField()
class JSONAPIMeta:
resource_name = "attack-paths-query-attributions"
class AttackPathsQueryParameterSerializer(BaseSerializerV1):
name = serializers.CharField()
label = serializers.CharField()
@@ -1190,7 +1198,9 @@ class AttackPathsQueryParameterSerializer(BaseSerializerV1):
class AttackPathsQuerySerializer(BaseSerializerV1):
id = serializers.CharField()
name = serializers.CharField()
short_description = serializers.CharField()
description = serializers.CharField()
attribution = AttackPathsQueryAttributionSerializer(allow_null=True, required=False)
provider = serializers.CharField()
parameters = AttackPathsQueryParameterSerializer(many=True)
@@ -1503,6 +1513,20 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
serializer = MongoDBAtlasProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.ALIBABACLOUD.value:
serializer = AlibabaCloudProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.CLOUDFLARE.value:
if "api_token" in secret:
serializer = CloudflareTokenProviderSecret(data=secret)
elif "api_key" in secret and "api_email" in secret:
serializer = CloudflareApiKeyProviderSecret(data=secret)
else:
raise serializers.ValidationError(
{
"secret": "Cloudflare credentials must include either 'api_token' "
"or both 'api_key' and 'api_email'."
}
)
elif provider_type == Provider.ProviderChoices.IMAGE.value:
serializer = ImageProviderSecret(data=secret)
else:
raise serializers.ValidationError(
{"provider": f"Provider type not supported {provider_type}"}
@@ -1641,6 +1665,37 @@ class IacProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class ImageProviderSecret(serializers.Serializer):
registry_username = serializers.CharField(required=False)
registry_password = serializers.CharField(required=False)
registry_token = serializers.CharField(required=False)
image_filter = serializers.CharField(required=False)
tag_filter = serializers.CharField(required=False)
max_images = serializers.IntegerField(required=False, min_value=0)
def validate(self, attrs):
has_username = attrs.get("registry_username")
has_password = attrs.get("registry_password")
has_token = attrs.get("registry_token")
if (has_username or has_password) and has_token:
raise serializers.ValidationError(
"You cannot provide both registry_username/registry_password and registry_token."
)
if has_username and not has_password:
raise serializers.ValidationError(
"registry_password is required when registry_username is provided."
)
if has_password and not has_username:
raise serializers.ValidationError(
"registry_username is required when registry_password is provided."
)
return super().validate(attrs)
class Meta:
resource_name = "provider-secrets"
class OracleCloudProviderSecret(serializers.Serializer):
user = serializers.CharField()
fingerprint = serializers.CharField()
@@ -1654,6 +1709,21 @@ class OracleCloudProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class CloudflareTokenProviderSecret(serializers.Serializer):
api_token = serializers.CharField()
class Meta:
resource_name = "provider-secrets"
class CloudflareApiKeyProviderSecret(serializers.Serializer):
api_key = serializers.CharField()
api_email = serializers.EmailField()
class Meta:
resource_name = "provider-secrets"
class AlibabaCloudProviderSecret(serializers.Serializer):
access_key_id = serializers.CharField()
access_key_secret = serializers.CharField()
+49 -33
View File
@@ -284,7 +284,10 @@ from api.v1.serializers import (
UserSerializer,
UserUpdateSerializer,
)
from prowler.providers.aws.exceptions.exceptions import AWSCredentialsError
from prowler.providers.aws.exceptions.exceptions import (
AWSAssumeRoleError,
AWSCredentialsError,
)
from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import (
CloudTrailTimeline,
)
@@ -389,7 +392,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.19.0"
spectacular_settings.VERSION = "1.20.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -760,27 +763,40 @@ class TenantFinishACSView(FinishACSView):
.tenant
)
# Check if tenant has only one user with MANAGE_ACCOUNT role
users_with_manage_account = (
role_name = (
extra.get("userType", ["no_permissions"])[0].strip()
if extra.get("userType")
else "no_permissions"
)
role = (
Role.objects.using(MainRouter.admin_db)
.filter(name=role_name, tenant=tenant)
.first()
)
# Only skip mapping if it would remove the last MANAGE_ACCOUNT user
remaining_manage_account_users = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role__manage_account=True, tenant_id=tenant.id)
.exclude(user_id=user_id)
.values("user")
.distinct()
.count()
)
user_has_manage_account = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id)
.exists()
)
role_manage_account = role.manage_account if role else False
would_remove_last_manage_account = (
user_has_manage_account
and remaining_manage_account_users == 0
and not role_manage_account
)
# Only apply role mapping from userType if tenant does NOT have exactly one user with MANAGE_ACCOUNT
if users_with_manage_account != 1:
role_name = (
extra.get("userType", ["no_permissions"])[0].strip()
if extra.get("userType")
else "no_permissions"
)
try:
role = Role.objects.using(MainRouter.admin_db).get(
name=role_name, tenant=tenant
)
except Role.DoesNotExist:
if not would_remove_last_manage_account:
if role is None:
role = Role.objects.using(MainRouter.admin_db).create(
name=role_name,
tenant=tenant,
@@ -2284,7 +2300,7 @@ class TaskViewSet(BaseRLSViewSet):
),
attack_paths_queries=extend_schema(
tags=["Attack Paths"],
summary="List attack paths queries",
summary="List Attack Paths queries",
description="Retrieve the catalog of Attack Paths queries available for this Attack Paths scan.",
responses={
200: OpenApiResponse(AttackPathsQuerySerializer(many=True)),
@@ -2304,7 +2320,7 @@ class TaskViewSet(BaseRLSViewSet):
description="Bad request (e.g., Unknown Attack Paths query for the selected provider)"
),
404: OpenApiResponse(
description="No attack paths found for the given query and parameters"
description="No Attack Paths found for the given query and parameters"
),
500: OpenApiResponse(
description="Attack Paths query execution failed due to a database error"
@@ -2412,15 +2428,6 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
}
)
if not attack_paths_scan.graph_database:
logger.error(
f"The Attack Paths Scan {attack_paths_scan.id} does not reference a graph database"
)
return Response(
{"detail": "The Attack Paths scan does not reference a graph database"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
payload = attack_paths_views_helpers.normalize_run_payload(request.data)
serializer = AttackPathsQueryRunRequestSerializer(data=payload)
serializer.is_valid(raise_exception=True)
@@ -2434,6 +2441,9 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
{"id": "Unknown Attack Paths query for the selected provider"}
)
database_name = graph_database.get_database_name(
attack_paths_scan.provider.tenant_id
)
parameters = attack_paths_views_helpers.prepare_query_parameters(
query_definition,
serializer.validated_data.get("parameters", {}),
@@ -2441,9 +2451,9 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
)
graph = attack_paths_views_helpers.execute_attack_paths_query(
attack_paths_scan, query_definition, parameters
database_name, query_definition, parameters
)
graph_database.clear_cache(attack_paths_scan.graph_database)
graph_database.clear_cache(database_name)
status_code = status.HTTP_200_OK
if not graph.get("nodes"):
@@ -3044,19 +3054,25 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
raise UpstreamAuthenticationError(
detail="Credentials not found for this provider. Please reconnect the provider."
)
except AWSAssumeRoleError:
# AssumeRole failed - usually IAM permission issue (not authorized to sts:AssumeRole)
raise UpstreamAccessDeniedError(
detail="Cannot assume role for this provider. Check IAM Role permissions and trust relationship."
)
except AWSCredentialsError:
# Handles expired tokens, invalid keys, profile not found, etc.
raise UpstreamAuthenticationError()
except ClientError as e:
error_code = e.response.get("Error", {}).get("Code", "")
# AccessDenied is expected when credentials lack permissions - don't log as error
if error_code in ("AccessDenied", "AccessDeniedException"):
raise UpstreamAccessDeniedError()
# Unexpected ClientErrors should be logged for debugging
logger.error(
f"Provider API error retrieving events: {str(e)}",
exc_info=True,
)
# AccessDenied means the credentials don't have the required permissions
if error_code in ("AccessDenied", "AccessDeniedException"):
raise UpstreamAccessDeniedError()
raise UpstreamServiceUnavailableError()
except Exception as e:
sentry_sdk.capture_exception(e)
+21 -10
View File
@@ -1,11 +1,9 @@
import logging
from types import SimpleNamespace
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from allauth.socialaccount.models import SocialLogin
from django.conf import settings
from django.db import connection as django_connection
@@ -14,6 +12,11 @@ from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.test import APIClient
from tasks.jobs.backfill import (
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
backfill_scan_resource_group_summaries,
)
from api.attack_paths import (
AttackPathsQueryDefinition,
@@ -59,11 +62,6 @@ from api.rls import Tenant
from api.v1.serializers import TokenSerializer
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from tasks.jobs.backfill import (
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
backfill_scan_resource_group_summaries,
)
TODAY = str(datetime.today().date())
API_JSON_CONTENT_TYPE = "application/vnd.api+json"
@@ -533,6 +531,18 @@ def providers_fixture(tenants_fixture):
alias="alibabacloud_testing",
tenant_id=tenant.id,
)
provider10 = Provider.objects.create(
provider="cloudflare",
uid="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
alias="cloudflare_testing",
tenant_id=tenant.id,
)
provider11 = Provider.objects.create(
provider="image",
uid="ghcr.io",
alias="image_testing",
tenant_id=tenant.id,
)
return (
provider1,
@@ -544,6 +554,8 @@ def providers_fixture(tenants_fixture):
provider7,
provider8,
provider9,
provider10,
provider11,
)
@@ -1613,7 +1625,6 @@ def create_attack_paths_scan():
scan=None,
state=StateChoices.COMPLETED,
progress=0,
graph_database="tenant-db",
**extra_fields,
):
scan_instance = scan or Scan.objects.create(
@@ -1630,7 +1641,6 @@ def create_attack_paths_scan():
"scan": scan_instance,
"state": state,
"progress": progress,
"graph_database": graph_database,
}
payload.update(extra_fields)
@@ -1658,6 +1668,7 @@ def attack_paths_query_definition_factory():
definition_payload = {
"id": "aws-test",
"name": "Attack Paths Test Query",
"short_description": "Synthetic short description for tests.",
"description": "Synthetic Attack Paths definition for tests.",
"provider": "aws",
"cypher": "RETURN 1",
@@ -29,7 +29,7 @@ def start_aws_ingestion(
attack_paths_scan: ProwlerAPIAttackPathsScan,
) -> dict[str, dict[str, str]]:
"""
Code based on Cartography version 0.122.0, specifically on `cartography.intel.aws.__init__.py`.
Code based on Cartography, specifically on `cartography.intel.aws.__init__.py`.
For the scan progress updates:
- The caller of this function (`tasks.jobs.attack_paths.scan.run`) has set it to 2.
@@ -0,0 +1,86 @@
from dataclasses import dataclass
from typing import Callable
from config.env import env
from tasks.jobs.attack_paths import aws
# Batch size for Neo4j operations
BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
# Neo4j internal labels (Prowler-specific, not provider-specific)
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources.
# - `ProviderResource`: Added to ALL synced nodes for provider isolation and drop/query ops.
PROWLER_FINDING_LABEL = "ProwlerFinding"
PROVIDER_RESOURCE_LABEL = "ProviderResource"
@dataclass(frozen=True)
class ProviderConfig:
"""Configuration for a cloud provider's Attack Paths integration."""
name: str
root_node_label: str # e.g., "AWSAccount"
uid_field: str # e.g., "arn"
# Label for resources connected to the account node, enabling indexed finding lookups.
resource_label: str # e.g., "AWSResource"
ingestion_function: Callable
# Provider Configurations
# -----------------------
AWS_CONFIG = ProviderConfig(
name="aws",
root_node_label="AWSAccount",
uid_field="arn",
resource_label="AWSResource",
ingestion_function=aws.start_aws_ingestion,
)
PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
"aws": AWS_CONFIG,
}
# Labels added by Prowler that should be filtered from API responses
# Derived from provider configs + common internal labels
INTERNAL_LABELS: list[str] = [
"Tenant",
PROVIDER_RESOURCE_LABEL,
# Add all provider-specific resource labels
*[config.resource_label for config in PROVIDER_CONFIGS.values()],
]
# Provider Config Accessors
# -------------------------
def is_provider_available(provider_type: str) -> bool:
"""Check if a provider type is available for Attack Paths scans."""
return provider_type in PROVIDER_CONFIGS
def get_cartography_ingestion_function(provider_type: str) -> Callable | None:
"""Get the Cartography ingestion function for a provider type."""
config = PROVIDER_CONFIGS.get(provider_type)
return config.ingestion_function if config else None
def get_root_node_label(provider_type: str) -> str:
"""Get the root node label for a provider type (e.g., AWSAccount)."""
config = PROVIDER_CONFIGS.get(provider_type)
return config.root_node_label if config else "UnknownProviderAccount"
def get_node_uid_field(provider_type: str) -> str:
"""Get the UID field for a provider type (e.g., arn for AWS)."""
config = PROVIDER_CONFIGS.get(provider_type)
return config.uid_field if config else "UnknownProviderUID"
def get_provider_resource_label(provider_type: str) -> str:
"""Get the resource label for a provider type (e.g., `AWSResource`)."""
config = PROVIDER_CONFIGS.get(provider_type)
return config.resource_label if config else "UnknownProviderResource"
@@ -1,7 +1,6 @@
from datetime import datetime, timezone
from typing import Any
from django.db.models import Q
from cartography.config import Config as CartographyConfig
from api.db_utils import rls_transaction
@@ -10,7 +9,7 @@ from api.models import (
Provider as ProwlerAPIProvider,
StateChoices,
)
from tasks.jobs.attack_paths.providers import is_provider_available
from tasks.jobs.attack_paths.config import is_provider_available
def can_provider_run_attack_paths_scan(tenant_id: str, provider_id: int) -> bool:
@@ -145,24 +144,3 @@ def update_old_attack_paths_scan(
with rls_transaction(old_attack_paths_scan.tenant_id):
old_attack_paths_scan.is_graph_database_deleted = True
old_attack_paths_scan.save(update_fields=["is_graph_database_deleted"])
def get_provider_graph_database_names(tenant_id: str, provider_id: str) -> list[str]:
"""
Return existing graph database names for a tenant/provider.
Note: For accesing the `AttackPathsScan` we need to use `all_objects` manager because the provider is soft-deleted.
"""
with rls_transaction(tenant_id):
graph_databases_names_qs = (
ProwlerAPIAttackPathsScan.all_objects.filter(
~Q(graph_database=""),
graph_database__isnull=False,
provider_id=provider_id,
is_graph_database_deleted=False,
)
.values_list("graph_database", flat=True)
.distinct()
)
return list(graph_databases_names_qs)
@@ -0,0 +1,355 @@
"""
Prowler findings ingestion into Neo4j graph.
This module handles:
- Adding resource labels to Cartography nodes for efficient lookups
- Loading Prowler findings into the graph
- Linking findings to resources
- Cleaning up stale findings
"""
from collections import defaultdict
from dataclasses import asdict, dataclass, fields
from typing import Any, Generator
from uuid import UUID
import neo4j
from cartography.config import Config as CartographyConfig
from celery.utils.log import get_task_logger
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
from api.models import Finding as FindingModel
from api.models import Provider, ResourceFindingMapping
from prowler.config import config as ProwlerConfig
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
get_node_uid_field,
get_provider_resource_label,
get_root_node_label,
)
from tasks.jobs.attack_paths.indexes import IndexType, create_indexes
from tasks.jobs.attack_paths.queries import (
ADD_RESOURCE_LABEL_TEMPLATE,
CLEANUP_FINDINGS_TEMPLATE,
INSERT_FINDING_TEMPLATE,
render_cypher_template,
)
logger = get_task_logger(__name__)
# Type Definitions
# -----------------
# Maps dataclass field names to Django ORM query field names
_DB_FIELD_MAP: dict[str, str] = {
"check_title": "check_metadata__checktitle",
}
@dataclass(slots=True)
class Finding:
"""
Finding data for Neo4j ingestion.
Can be created from a Django .values() query result using from_db_record().
"""
id: str
uid: str
inserted_at: str
updated_at: str
first_seen_at: str
scan_id: str
delta: str
status: str
status_extended: str
severity: str
check_id: str
check_title: str
muted: bool
muted_reason: str | None
resource_uid: str | None = None
@classmethod
def get_db_query_fields(cls) -> tuple[str, ...]:
"""Get field names for Django .values() query."""
return tuple(
_DB_FIELD_MAP.get(f.name, f.name)
for f in fields(cls)
if f.name != "resource_uid"
)
@classmethod
def from_db_record(cls, record: dict[str, Any], resource_uid: str) -> "Finding":
"""Create a Finding from a Django .values() query result."""
return cls(
id=str(record["id"]),
uid=record["uid"],
inserted_at=record["inserted_at"],
updated_at=record["updated_at"],
first_seen_at=record["first_seen_at"],
scan_id=str(record["scan_id"]),
delta=record["delta"],
status=record["status"],
status_extended=record["status_extended"],
severity=record["severity"],
check_id=str(record["check_id"]),
check_title=record["check_metadata__checktitle"],
muted=record["muted"],
muted_reason=record["muted_reason"],
resource_uid=resource_uid,
)
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for Neo4j ingestion."""
return asdict(self)
# Public API
# ----------
def create_findings_indexes(neo4j_session: neo4j.Session) -> None:
"""Create indexes for Prowler findings and resource lookups."""
create_indexes(neo4j_session, IndexType.FINDINGS)
def analysis(
neo4j_session: neo4j.Session,
prowler_api_provider: Provider,
scan_id: str,
config: CartographyConfig,
) -> None:
"""
Main entry point for Prowler findings analysis.
Adds resource labels, loads findings, and cleans up stale data.
"""
add_resource_label(
neo4j_session, prowler_api_provider.provider, str(prowler_api_provider.uid)
)
findings_data = stream_findings_with_resources(prowler_api_provider, scan_id)
load_findings(neo4j_session, findings_data, prowler_api_provider, config)
cleanup_findings(neo4j_session, prowler_api_provider, config)
def add_resource_label(
neo4j_session: neo4j.Session, provider_type: str, provider_uid: str
) -> int:
"""
Add a common resource label to all nodes connected to the provider account.
This enables index usage for resource lookups in the findings query,
since Cartography nodes don't have a common parent label.
Returns the total number of nodes labeled.
"""
query = render_cypher_template(
ADD_RESOURCE_LABEL_TEMPLATE,
{
"__ROOT_LABEL__": get_root_node_label(provider_type),
"__RESOURCE_LABEL__": get_provider_resource_label(provider_type),
},
)
logger.info(
f"Adding {get_provider_resource_label(provider_type)} label to all resources for {provider_uid}"
)
total_labeled = 0
labeled_count = 1
while labeled_count > 0:
result = neo4j_session.run(
query,
{"provider_uid": provider_uid, "batch_size": BATCH_SIZE},
)
labeled_count = result.single().get("labeled_count", 0)
total_labeled += labeled_count
if labeled_count > 0:
logger.info(
f"Labeled {total_labeled} nodes with {get_provider_resource_label(provider_type)}"
)
return total_labeled
def load_findings(
neo4j_session: neo4j.Session,
findings_batches: Generator[list[Finding], None, None],
prowler_api_provider: Provider,
config: CartographyConfig,
) -> None:
"""Load Prowler findings into the graph, linking them to resources."""
query = render_cypher_template(
INSERT_FINDING_TEMPLATE,
{
"__ROOT_NODE_LABEL__": get_root_node_label(prowler_api_provider.provider),
"__NODE_UID_FIELD__": get_node_uid_field(prowler_api_provider.provider),
"__RESOURCE_LABEL__": get_provider_resource_label(
prowler_api_provider.provider
),
},
)
parameters = {
"provider_uid": str(prowler_api_provider.uid),
"last_updated": config.update_tag,
"prowler_version": ProwlerConfig.prowler_version,
}
batch_num = 0
total_records = 0
for batch in findings_batches:
batch_num += 1
batch_size = len(batch)
total_records += batch_size
parameters["findings_data"] = [f.to_dict() for f in batch]
logger.info(f"Loading findings batch {batch_num} ({batch_size} records)")
neo4j_session.run(query, parameters)
logger.info(f"Finished loading {total_records} records in {batch_num} batches")
def cleanup_findings(
neo4j_session: neo4j.Session,
prowler_api_provider: Provider,
config: CartographyConfig,
) -> None:
"""Remove stale findings (classic Cartography behaviour)."""
parameters = {
"provider_uid": str(prowler_api_provider.uid),
"last_updated": config.update_tag,
"batch_size": BATCH_SIZE,
}
batch = 1
deleted_count = 1
while deleted_count > 0:
logger.info(f"Cleaning findings batch {batch}")
result = neo4j_session.run(CLEANUP_FINDINGS_TEMPLATE, parameters)
deleted_count = result.single().get("deleted_findings_count", 0)
batch += 1
# Findings Streaming (Generator-based)
# -------------------------------------
def stream_findings_with_resources(
prowler_api_provider: Provider,
scan_id: str,
) -> Generator[list[Finding], None, None]:
"""
Stream findings with their associated resources in batches.
Uses keyset pagination for efficient traversal of large datasets.
Memory efficient: yields one batch at a time, never holds all findings in memory.
"""
logger.info(
f"Starting findings stream for scan {scan_id} "
f"(tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}"
)
tenant_id = prowler_api_provider.tenant_id
for batch in _paginate_findings(tenant_id, scan_id):
enriched = _enrich_batch_with_resources(batch, tenant_id)
if enriched:
yield enriched
logger.info(f"Finished streaming findings for scan {scan_id}")
def _paginate_findings(
tenant_id: str,
scan_id: str,
) -> Generator[list[dict[str, Any]], None, None]:
"""
Paginate through findings using keyset pagination.
Each iteration fetches one batch within its own RLS transaction,
preventing long-held database connections.
"""
last_id = None
iteration = 0
while True:
iteration += 1
batch = _fetch_findings_batch(tenant_id, scan_id, last_id)
logger.info(f"Iteration #{iteration}: fetched {len(batch)} findings")
if not batch:
break
last_id = batch[-1]["id"]
yield batch
def _fetch_findings_batch(
tenant_id: str,
scan_id: str,
after_id: UUID | None,
) -> list[dict[str, Any]]:
"""
Fetch a single batch of findings from the database.
Uses read replica and RLS-scoped transaction.
"""
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
# Use all_objects to avoid the ActiveProviderManager's implicit JOIN
# through Scan -> Provider (to check is_deleted=False).
# The provider is already validated as active in this context.
qs = FindingModel.all_objects.filter(scan_id=scan_id).order_by("id")
if after_id is not None:
qs = qs.filter(id__gt=after_id)
return list(qs.values(*Finding.get_db_query_fields())[:BATCH_SIZE])
# Batch Enrichment
# -----------------
def _enrich_batch_with_resources(
findings_batch: list[dict[str, Any]],
tenant_id: str,
) -> list[Finding]:
"""
Enrich findings with their resource UIDs.
One finding with N resources becomes N output records.
Findings without resources are skipped.
"""
finding_ids = [f["id"] for f in findings_batch]
resource_map = _build_finding_resource_map(finding_ids, tenant_id)
return [
Finding.from_db_record(finding, resource_uid)
for finding in findings_batch
for resource_uid in resource_map.get(finding["id"], [])
]
def _build_finding_resource_map(
finding_ids: list[UUID], tenant_id: str
) -> dict[UUID, list[str]]:
"""Build mapping from finding_id to list of resource UIDs."""
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
resource_mappings = ResourceFindingMapping.objects.filter(
finding_id__in=finding_ids
).values_list("finding_id", "resource__uid")
result = defaultdict(list)
for finding_id, resource_uid in resource_mappings:
result[finding_id].append(resource_uid)
return result
@@ -0,0 +1,64 @@
from enum import Enum
import neo4j
from cartography.client.core.tx import run_write_query
from celery.utils.log import get_task_logger
from tasks.jobs.attack_paths.config import (
PROWLER_FINDING_LABEL,
PROVIDER_RESOURCE_LABEL,
)
logger = get_task_logger(__name__)
class IndexType(Enum):
"""Types of indexes that can be created."""
FINDINGS = "findings"
SYNC = "sync"
# Indexes for Prowler findings and resource lookups
FINDINGS_INDEX_STATEMENTS = [
# Resources indexes for quick Prowler Finding lookups
"CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:AWSResource) ON (n.arn);",
"CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:AWSResource) ON (n.id);",
# Prowler Finding indexes
f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);",
f"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.provider_uid);",
f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);",
f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);",
]
# Indexes for provider resource sync operations
SYNC_INDEX_STATEMENTS = [
f"CREATE INDEX provider_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.provider_element_id);",
f"CREATE INDEX provider_resource_provider_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.provider_id);",
]
def create_indexes(neo4j_session: neo4j.Session, index_type: IndexType) -> None:
"""
Create indexes for the specified type.
Args:
`neo4j_session`: The Neo4j session to use
`index_type`: The type of indexes to create (FINDINGS or SYNC)
"""
if index_type == IndexType.FINDINGS:
logger.info("Creating indexes for Prowler Findings node types")
for statement in FINDINGS_INDEX_STATEMENTS:
run_write_query(neo4j_session, statement)
elif index_type == IndexType.SYNC:
logger.info("Ensuring ProviderResource indexes exist")
for statement in SYNC_INDEX_STATEMENTS:
neo4j_session.run(statement)
def create_all_indexes(neo4j_session: neo4j.Session) -> None:
"""Create all indexes (both findings and sync)."""
create_indexes(neo4j_session, IndexType.FINDINGS)
create_indexes(neo4j_session, IndexType.SYNC)
@@ -1,23 +0,0 @@
AVAILABLE_PROVIDERS: list[str] = [
"aws",
]
ROOT_NODE_LABELS: dict[str, str] = {
"aws": "AWSAccount",
}
NODE_UID_FIELDS: dict[str, str] = {
"aws": "arn",
}
def is_provider_available(provider_type: str) -> bool:
return provider_type in AVAILABLE_PROVIDERS
def get_root_node_label(provider_type: str) -> str:
return ROOT_NODE_LABELS.get(provider_type, "UnknownProviderAccount")
def get_node_uid_field(provider_type: str) -> str:
return NODE_UID_FIELDS.get(provider_type, "UnknownProviderUID")
@@ -1,290 +0,0 @@
from collections import defaultdict
from typing import Generator
import neo4j
from cartography.client.core.tx import run_write_query
from cartography.config import Config as CartographyConfig
from celery.utils.log import get_task_logger
from config.env import env
from tasks.jobs.attack_paths.providers import get_node_uid_field, get_root_node_label
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
from api.models import Finding, Provider, ResourceFindingMapping
from prowler.config import config as ProwlerConfig
logger = get_task_logger(__name__)
BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000)
INDEX_STATEMENTS = [
"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.id);",
"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.provider_uid);",
"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.lastupdated);",
"CREATE INDEX prowler_finding_check_id IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.status);",
]
INSERT_STATEMENT_TEMPLATE = """
MATCH (account:__ROOT_NODE_LABEL__ {id: $provider_uid})
UNWIND $findings_data AS finding_data
OPTIONAL MATCH (account)-->(resource_by_uid)
WHERE resource_by_uid.__NODE_UID_FIELD__ = finding_data.resource_uid
WITH account, finding_data, resource_by_uid
OPTIONAL MATCH (account)-->(resource_by_id)
WHERE resource_by_uid IS NULL
AND resource_by_id.id = finding_data.resource_uid
WITH account, finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource
WHERE resource IS NOT NULL
MERGE (finding:ProwlerFinding {id: finding_data.id})
ON CREATE SET
finding.id = finding_data.id,
finding.uid = finding_data.uid,
finding.inserted_at = finding_data.inserted_at,
finding.updated_at = finding_data.updated_at,
finding.first_seen_at = finding_data.first_seen_at,
finding.scan_id = finding_data.scan_id,
finding.delta = finding_data.delta,
finding.status = finding_data.status,
finding.status_extended = finding_data.status_extended,
finding.severity = finding_data.severity,
finding.check_id = finding_data.check_id,
finding.check_title = finding_data.check_title,
finding.muted = finding_data.muted,
finding.muted_reason = finding_data.muted_reason,
finding.provider_uid = $provider_uid,
finding.firstseen = timestamp(),
finding.lastupdated = $last_updated,
finding._module_name = 'cartography:prowler',
finding._module_version = $prowler_version
ON MATCH SET
finding.status = finding_data.status,
finding.status_extended = finding_data.status_extended,
finding.lastupdated = $last_updated
MERGE (resource)-[rel:HAS_FINDING]->(finding)
ON CREATE SET
rel.provider_uid = $provider_uid,
rel.firstseen = timestamp(),
rel.lastupdated = $last_updated,
rel._module_name = 'cartography:prowler',
rel._module_version = $prowler_version
ON MATCH SET
rel.lastupdated = $last_updated
"""
CLEANUP_STATEMENT = """
MATCH (finding:ProwlerFinding {provider_uid: $provider_uid})
WHERE finding.lastupdated < $last_updated
WITH finding LIMIT $batch_size
DETACH DELETE finding
RETURN COUNT(finding) AS deleted_findings_count
"""
def create_indexes(neo4j_session: neo4j.Session) -> None:
"""
Code based on Cartography version 0.122.0, specifically on `cartography.intel.create_indexes.run`.
"""
logger.info("Creating indexes for Prowler Findings node types")
for statement in INDEX_STATEMENTS:
run_write_query(neo4j_session, statement)
def analysis(
neo4j_session: neo4j.Session,
prowler_api_provider: Provider,
scan_id: str,
config: CartographyConfig,
) -> None:
findings_data = get_provider_last_scan_findings(prowler_api_provider, scan_id)
load_findings(neo4j_session, findings_data, prowler_api_provider, config)
cleanup_findings(neo4j_session, prowler_api_provider, config)
def get_provider_last_scan_findings(
prowler_api_provider: Provider,
scan_id: str,
) -> Generator[list[dict[str, str]], None, None]:
"""
Generator that yields batches of finding-resource pairs.
Two-step query approach per batch:
1. Paginate findings for scan (single table, indexed by scan_id)
2. Batch-fetch resource UIDs via mapping table (single join)
3. Merge and yield flat structure for Neo4j
Memory efficient: never holds more than BATCH_SIZE findings in memory.
"""
logger.info(
f"Starting findings fetch for scan {scan_id} (tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}"
)
iteration = 0
last_id = None
while True:
iteration += 1
with rls_transaction(prowler_api_provider.tenant_id, using=READ_REPLICA_ALIAS):
# Use all_objects to avoid the ActiveProviderManager's implicit JOIN
# through Scan -> Provider (to check is_deleted=False).
# The provider is already validated as active in this context.
qs = Finding.all_objects.filter(scan_id=scan_id).order_by("id")
if last_id is not None:
qs = qs.filter(id__gt=last_id)
findings_batch = list(
qs.values(
"id",
"uid",
"inserted_at",
"updated_at",
"first_seen_at",
"scan_id",
"delta",
"status",
"status_extended",
"severity",
"check_id",
"check_metadata__checktitle",
"muted",
"muted_reason",
)[:BATCH_SIZE]
)
logger.info(
f"Iteration #{iteration} fetched {len(findings_batch)} findings"
)
if not findings_batch:
logger.info(
f"No findings returned for iteration #{iteration}; stopping pagination"
)
break
last_id = findings_batch[-1]["id"]
enriched_batch = _enrich_and_flatten_batch(findings_batch)
# Yield outside the transaction
if enriched_batch:
yield enriched_batch
logger.info(f"Finished fetching findings for scan {scan_id}")
def _enrich_and_flatten_batch(
findings_batch: list[dict],
) -> list[dict[str, str]]:
"""
Fetch resource UIDs for a batch of findings and return flat structure.
One finding with 3 resources becomes 3 dicts (same output format as before).
Must be called within an RLS transaction context.
"""
finding_ids = [f["id"] for f in findings_batch]
# Single join: mapping -> resource
resource_mappings = ResourceFindingMapping.objects.filter(
finding_id__in=finding_ids
).values_list("finding_id", "resource__uid")
# Build finding_id -> [resource_uids] mapping
finding_resources = defaultdict(list)
for finding_id, resource_uid in resource_mappings:
finding_resources[finding_id].append(resource_uid)
# Flatten: one dict per (finding, resource) pair
results = []
for f in findings_batch:
resource_uids = finding_resources.get(f["id"], [])
if not resource_uids:
continue
for resource_uid in resource_uids:
results.append(
{
"resource_uid": str(resource_uid),
"id": str(f["id"]),
"uid": f["uid"],
"inserted_at": f["inserted_at"],
"updated_at": f["updated_at"],
"first_seen_at": f["first_seen_at"],
"scan_id": str(f["scan_id"]),
"delta": f["delta"],
"status": f["status"],
"status_extended": f["status_extended"],
"severity": f["severity"],
"check_id": str(f["check_id"]),
"check_title": f["check_metadata__checktitle"],
"muted": f["muted"],
"muted_reason": f["muted_reason"],
}
)
return results
def load_findings(
neo4j_session: neo4j.Session,
findings_batches: Generator[list[dict[str, str]], None, None],
prowler_api_provider: Provider,
config: CartographyConfig,
) -> None:
replacements = {
"__ROOT_NODE_LABEL__": get_root_node_label(prowler_api_provider.provider),
"__NODE_UID_FIELD__": get_node_uid_field(prowler_api_provider.provider),
}
query = INSERT_STATEMENT_TEMPLATE
for replace_key, replace_value in replacements.items():
query = query.replace(replace_key, replace_value)
parameters = {
"provider_uid": str(prowler_api_provider.uid),
"last_updated": config.update_tag,
"prowler_version": ProwlerConfig.prowler_version,
}
batch_num = 0
total_records = 0
for batch in findings_batches:
batch_num += 1
batch_size = len(batch)
total_records += batch_size
parameters["findings_data"] = batch
logger.info(f"Loading findings batch {batch_num} ({batch_size} records)")
neo4j_session.run(query, parameters)
logger.info(f"Finished loading {total_records} records in {batch_num} batches")
def cleanup_findings(
neo4j_session: neo4j.Session,
prowler_api_provider: Provider,
config: CartographyConfig,
) -> None:
parameters = {
"provider_uid": str(prowler_api_provider.uid),
"last_updated": config.update_tag,
"batch_size": BATCH_SIZE,
}
batch = 1
deleted_count = 1
while deleted_count > 0:
logger.info(f"Cleaning findings batch {batch}")
result = neo4j_session.run(CLEANUP_STATEMENT, parameters)
deleted_count = result.single().get("deleted_findings_count", 0)
batch += 1
@@ -0,0 +1,134 @@
# Cypher query templates for Attack Paths operations
from tasks.jobs.attack_paths.config import (
PROWLER_FINDING_LABEL,
PROVIDER_RESOURCE_LABEL,
)
def render_cypher_template(template: str, replacements: dict[str, str]) -> str:
"""
Render a Cypher query template by replacing placeholders.
Placeholders use `__DOUBLE_UNDERSCORE__` format to avoid conflicts
with Cypher syntax.
"""
query = template
for placeholder, value in replacements.items():
query = query.replace(placeholder, value)
return query
# Findings queries (used by findings.py)
# ---------------------------------------
ADD_RESOURCE_LABEL_TEMPLATE = """
MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r)
WHERE NOT r:__ROOT_LABEL__ AND NOT r:__RESOURCE_LABEL__
WITH r LIMIT $batch_size
SET r:__RESOURCE_LABEL__
RETURN COUNT(r) AS labeled_count
"""
INSERT_FINDING_TEMPLATE = f"""
MATCH (account:__ROOT_NODE_LABEL__ {{id: $provider_uid}})
UNWIND $findings_data AS finding_data
OPTIONAL MATCH (account)-->(resource_by_uid:__RESOURCE_LABEL__)
WHERE resource_by_uid.__NODE_UID_FIELD__ = finding_data.resource_uid
WITH account, finding_data, resource_by_uid
OPTIONAL MATCH (account)-->(resource_by_id:__RESOURCE_LABEL__)
WHERE resource_by_uid IS NULL
AND resource_by_id.id = finding_data.resource_uid
WITH account, finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource
WHERE resource IS NOT NULL
MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}})
ON CREATE SET
finding.id = finding_data.id,
finding.uid = finding_data.uid,
finding.inserted_at = finding_data.inserted_at,
finding.updated_at = finding_data.updated_at,
finding.first_seen_at = finding_data.first_seen_at,
finding.scan_id = finding_data.scan_id,
finding.delta = finding_data.delta,
finding.status = finding_data.status,
finding.status_extended = finding_data.status_extended,
finding.severity = finding_data.severity,
finding.check_id = finding_data.check_id,
finding.check_title = finding_data.check_title,
finding.muted = finding_data.muted,
finding.muted_reason = finding_data.muted_reason,
finding.provider_uid = $provider_uid,
finding.firstseen = timestamp(),
finding.lastupdated = $last_updated,
finding._module_name = 'cartography:prowler',
finding._module_version = $prowler_version
ON MATCH SET
finding.status = finding_data.status,
finding.status_extended = finding_data.status_extended,
finding.lastupdated = $last_updated
MERGE (resource)-[rel:HAS_FINDING]->(finding)
ON CREATE SET
rel.provider_uid = $provider_uid,
rel.firstseen = timestamp(),
rel.lastupdated = $last_updated,
rel._module_name = 'cartography:prowler',
rel._module_version = $prowler_version
ON MATCH SET
rel.lastupdated = $last_updated
"""
CLEANUP_FINDINGS_TEMPLATE = f"""
MATCH (finding:{PROWLER_FINDING_LABEL} {{provider_uid: $provider_uid}})
WHERE finding.lastupdated < $last_updated
WITH finding LIMIT $batch_size
DETACH DELETE finding
RETURN COUNT(finding) AS deleted_findings_count
"""
# Sync queries (used by sync.py)
# -------------------------------
NODE_FETCH_QUERY = """
MATCH (n)
WHERE id(n) > $last_id
RETURN id(n) AS internal_id,
elementId(n) AS element_id,
labels(n) AS labels,
properties(n) AS props
ORDER BY internal_id
LIMIT $batch_size
"""
RELATIONSHIPS_FETCH_QUERY = """
MATCH ()-[r]->()
WHERE id(r) > $last_id
RETURN id(r) AS internal_id,
type(r) AS rel_type,
elementId(startNode(r)) AS start_element_id,
elementId(endNode(r)) AS end_element_id,
properties(r) AS props
ORDER BY internal_id
LIMIT $batch_size
"""
NODE_SYNC_TEMPLATE = """
UNWIND $rows AS row
MERGE (n:__NODE_LABELS__ {provider_element_id: row.provider_element_id})
SET n += row.props
SET n.provider_id = $provider_id
"""
RELATIONSHIP_SYNC_TEMPLATE = f"""
UNWIND $rows AS row
MATCH (s:{PROVIDER_RESOURCE_LABEL} {{provider_element_id: row.start_element_id}})
MATCH (t:{PROVIDER_RESOURCE_LABEL} {{provider_element_id: row.end_element_id}})
MERGE (s)-[r:__REL_TYPE__ {{provider_element_id: row.provider_element_id}}]->(t)
SET r += row.props
SET r.provider_id = $provider_id
"""
+88 -55
View File
@@ -1,8 +1,7 @@
import logging
import time
import asyncio
from typing import Any, Callable
from typing import Any
from cartography.config import Config as CartographyConfig
from cartography.intel import analysis as cartography_analysis
@@ -17,7 +16,8 @@ from api.models import (
StateChoices,
)
from api.utils import initialize_prowler_provider
from tasks.jobs.attack_paths import aws, db_utils, prowler, utils
from tasks.jobs.attack_paths import db_utils, findings, sync, utils
from tasks.jobs.attack_paths.config import get_cartography_ingestion_function
# Without this Celery goes crazy with Cartography logging
logging.getLogger("cartography").setLevel(logging.ERROR)
@@ -25,18 +25,10 @@ logging.getLogger("neo4j").propagate = False
logger = get_task_logger(__name__)
CARTOGRAPHY_INGESTION_FUNCTIONS: dict[str, Callable] = {
"aws": aws.start_aws_ingestion,
}
def get_cartography_ingestion_function(provider_type: str) -> Callable | None:
return CARTOGRAPHY_INGESTION_FUNCTIONS.get(provider_type)
def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
"""
Code based on Cartography version 0.122.0, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`,
Code based on Cartography, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`,
`cartography.sync.run_with_config` and `cartography.sync.Sync.run`.
"""
ingestion_exceptions = {} # This will hold any exceptions raised during ingestion
@@ -76,22 +68,36 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
tenant_id, scan_id, prowler_api_provider.id
)
tmp_database_name = graph_database.get_database_name(
attack_paths_scan.id, temporary=True
)
tenant_database_name = graph_database.get_database_name(
prowler_api_provider.tenant_id
)
# While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object
cartography_config = CartographyConfig(
tmp_cartography_config = CartographyConfig(
neo4j_uri=graph_database.get_uri(),
neo4j_database=graph_database.get_database_name(attack_paths_scan.id),
neo4j_database=tmp_database_name,
update_tag=int(time.time()),
)
tenant_cartography_config = CartographyConfig(
neo4j_uri=tmp_cartography_config.neo4j_uri,
neo4j_database=tenant_database_name,
update_tag=tmp_cartography_config.update_tag,
)
# Starting the Attack Paths scan
db_utils.starting_attack_paths_scan(attack_paths_scan, task_id, cartography_config)
db_utils.starting_attack_paths_scan(
attack_paths_scan, task_id, tenant_cartography_config
)
try:
logger.info(
f"Creating Neo4j database {cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
)
graph_database.create_database(cartography_config.neo4j_database)
graph_database.create_database(tmp_cartography_config.neo4j_database)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 1)
logger.info(
@@ -99,18 +105,18 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}"
)
with graph_database.get_session(
cartography_config.neo4j_database
) as neo4j_session:
tmp_cartography_config.neo4j_database
) as tmp_neo4j_session:
# Indexes creation
cartography_create_indexes.run(neo4j_session, cartography_config)
prowler.create_indexes(neo4j_session)
cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config)
findings.create_findings_indexes(tmp_neo4j_session)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2)
# The real scan, where iterates over cloud services
ingestion_exceptions = _call_within_event_loop(
ingestion_exceptions = utils.call_within_event_loop(
cartography_ingestion_function,
neo4j_session,
cartography_config,
tmp_neo4j_session,
tmp_cartography_config,
prowler_api_provider,
prowler_sdk_provider,
attack_paths_scan,
@@ -120,43 +126,92 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
logger.info(
f"Syncing Cartography ontology for AWS account {prowler_api_provider.uid}"
)
cartography_ontology.run(neo4j_session, cartography_config)
cartography_ontology.run(tmp_neo4j_session, tmp_cartography_config)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95)
logger.info(
f"Syncing Cartography analysis for AWS account {prowler_api_provider.uid}"
)
cartography_analysis.run(neo4j_session, cartography_config)
cartography_analysis.run(tmp_neo4j_session, tmp_cartography_config)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96)
# Adding Prowler nodes and relationships
logger.info(
f"Syncing Prowler analysis for AWS account {prowler_api_provider.uid}"
)
prowler.analysis(
neo4j_session, prowler_api_provider, scan_id, cartography_config
findings.analysis(
tmp_neo4j_session, prowler_api_provider, scan_id, tmp_cartography_config
)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 97)
logger.info(
f"Clearing Neo4j cache for database {cartography_config.neo4j_database}"
f"Clearing Neo4j cache for database {tmp_cartography_config.neo4j_database}"
)
graph_database.clear_cache(cartography_config.neo4j_database)
graph_database.clear_cache(tmp_cartography_config.neo4j_database)
logger.info(
f"Ensuring tenant database {tenant_database_name}, and its indexes, exists for tenant {prowler_api_provider.tenant_id}"
)
graph_database.create_database(tenant_database_name)
with graph_database.get_session(tenant_database_name) as tenant_neo4j_session:
cartography_create_indexes.run(
tenant_neo4j_session, tenant_cartography_config
)
findings.create_findings_indexes(tenant_neo4j_session)
sync.create_sync_indexes(tenant_neo4j_session)
logger.info(f"Deleting existing provider graph in {tenant_database_name}")
graph_database.drop_subgraph(
database=tenant_database_name,
provider_id=str(prowler_api_provider.id),
)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 98)
logger.info(
f"Syncing graph from {tmp_database_name} into {tenant_database_name}"
)
sync.sync_graph(
source_database=tmp_database_name,
target_database=tenant_database_name,
provider_id=str(prowler_api_provider.id),
)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99)
logger.info(f"Clearing Neo4j cache for database {tenant_database_name}")
graph_database.clear_cache(tenant_database_name)
logger.info(
f"Completed Cartography ({attack_paths_scan.id}) for "
f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}"
)
# Handling databases changes
# TODO
# This piece of code delete old Neo4j databases for this tenant's provider
# When we clean all of these databases we need to:
# - Delete this block
# - Delete function from `db_utils` the functions get_old_attack_paths_scans` & `update_old_attack_paths_scan`
# - Remove `graph_database` & `is_graph_database_deleted` from the AttackPathsScan model:
# - Check indexes
# - Create migration
# - The use of `attack_paths_scan.graph_database` on `views` and `views_helpers`
# - Tests
old_attack_paths_scans = db_utils.get_old_attack_paths_scans(
prowler_api_provider.tenant_id,
prowler_api_provider.id,
attack_paths_scan.id,
)
for old_attack_paths_scan in old_attack_paths_scans:
graph_database.drop_database(old_attack_paths_scan.graph_database)
old_graph_database = old_attack_paths_scan.graph_database
if old_graph_database and old_graph_database != tenant_database_name:
logger.info(
f"Dropping old Neo4j database {old_graph_database} for provider {prowler_api_provider.id}"
)
graph_database.drop_database(old_graph_database)
db_utils.update_old_attack_paths_scan(old_attack_paths_scan)
logger.info(f"Dropping temporary Neo4j database {tmp_database_name}")
graph_database.drop_database(tmp_database_name)
db_utils.finish_attack_paths_scan(
attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions
)
@@ -168,30 +223,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
ingestion_exceptions["global_cartography_error"] = exception_message
# Handling databases changes
graph_database.drop_database(cartography_config.neo4j_database)
graph_database.drop_database(tmp_cartography_config.neo4j_database)
db_utils.finish_attack_paths_scan(
attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
)
raise
def _call_within_event_loop(fn, *args, **kwargs):
"""
Cartography needs a running event loop, so assuming there is none (Celery task or even regular DRF endpoint),
let's create a new one and set it as the current event loop for this thread.
"""
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return fn(*args, **kwargs)
finally:
try:
loop.run_until_complete(loop.shutdown_asyncgens())
except Exception as e:
logger.warning(f"Failed to shutdown async generators cleanly: {e}")
loop.close()
asyncio.set_event_loop(None)
@@ -0,0 +1,202 @@
"""
Graph sync operations for Attack Paths.
This module handles syncing graph data from temporary scan databases
to the tenant database, adding provider isolation labels and properties.
"""
from collections import defaultdict
from typing import Any
from celery.utils.log import get_task_logger
from api.attack_paths import database as graph_database
from tasks.jobs.attack_paths.config import BATCH_SIZE, PROVIDER_RESOURCE_LABEL
from tasks.jobs.attack_paths.indexes import IndexType, create_indexes
from tasks.jobs.attack_paths.queries import (
NODE_FETCH_QUERY,
NODE_SYNC_TEMPLATE,
RELATIONSHIP_SYNC_TEMPLATE,
RELATIONSHIPS_FETCH_QUERY,
render_cypher_template,
)
logger = get_task_logger(__name__)
def create_sync_indexes(neo4j_session) -> None:
"""Create indexes for provider resource sync operations."""
create_indexes(neo4j_session, IndexType.SYNC)
def sync_graph(
source_database: str,
target_database: str,
provider_id: str,
) -> dict[str, int]:
"""
Sync all nodes and relationships from source to target database.
Args:
`source_database`: The temporary scan database
`target_database`: The tenant database
`provider_id`: The provider ID for isolation
Returns:
Dict with counts of synced nodes and relationships
"""
nodes_synced = sync_nodes(
source_database,
target_database,
provider_id,
)
relationships_synced = sync_relationships(
source_database,
target_database,
provider_id,
)
return {
"nodes": nodes_synced,
"relationships": relationships_synced,
}
def sync_nodes(
source_database: str,
target_database: str,
provider_id: str,
) -> int:
"""
Sync nodes from source to target database.
Adds `ProviderResource` label and `provider_id` property to all nodes.
"""
last_id = -1
total_synced = 0
with (
graph_database.get_session(source_database) as source_session,
graph_database.get_session(target_database) as target_session,
):
while True:
rows = list(
source_session.run(
NODE_FETCH_QUERY,
{"last_id": last_id, "batch_size": BATCH_SIZE},
)
)
if not rows:
break
last_id = rows[-1]["internal_id"]
grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
for row in rows:
labels = tuple(sorted(set(row["labels"] or [])))
props = dict(row["props"] or {})
_strip_internal_properties(props)
provider_element_id = f"{provider_id}:{row['element_id']}"
grouped[labels].append(
{
"provider_element_id": provider_element_id,
"props": props,
}
)
for labels, batch in grouped.items():
label_set = set(labels)
label_set.add(PROVIDER_RESOURCE_LABEL)
node_labels = ":".join(f"`{label}`" for label in sorted(label_set))
query = render_cypher_template(
NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels}
)
target_session.run(
query,
{
"rows": batch,
"provider_id": provider_id,
},
)
total_synced += len(rows)
logger.info(
f"Synced {total_synced} nodes from {source_database} to {target_database}"
)
return total_synced
def sync_relationships(
source_database: str,
target_database: str,
provider_id: str,
) -> int:
"""
Sync relationships from source to target database.
Adds `provider_id` property to all relationships.
"""
last_id = -1
total_synced = 0
with (
graph_database.get_session(source_database) as source_session,
graph_database.get_session(target_database) as target_session,
):
while True:
rows = list(
source_session.run(
RELATIONSHIPS_FETCH_QUERY,
{"last_id": last_id, "batch_size": BATCH_SIZE},
)
)
if not rows:
break
last_id = rows[-1]["internal_id"]
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
props = dict(row["props"] or {})
_strip_internal_properties(props)
rel_type = row["rel_type"]
grouped[rel_type].append(
{
"start_element_id": f"{provider_id}:{row['start_element_id']}",
"end_element_id": f"{provider_id}:{row['end_element_id']}",
"provider_element_id": f"{provider_id}:{rel_type}:{row['internal_id']}",
"props": props,
}
)
for rel_type, batch in grouped.items():
query = render_cypher_template(
RELATIONSHIP_SYNC_TEMPLATE, {"__REL_TYPE__": rel_type}
)
target_session.run(
query,
{
"rows": batch,
"provider_id": provider_id,
},
)
total_synced += len(rows)
logger.info(
f"Synced {total_synced} relationships from {source_database} to {target_database}"
)
return total_synced
def _strip_internal_properties(props: dict[str, Any]) -> None:
"""Remove internal properties that shouldn't be copied during sync."""
for key in [
"provider_element_id",
"provider_id",
]:
props.pop(key, None)
@@ -1,10 +1,40 @@
import asyncio
import traceback
from datetime import datetime, timezone
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
def stringify_exception(exception: Exception, context: str) -> str:
"""Format an exception with timestamp and traceback for logging."""
timestamp = datetime.now(tz=timezone.utc)
exception_traceback = traceback.TracebackException.from_exception(exception)
traceback_string = "".join(exception_traceback.format())
return f"{timestamp} - {context}\n{traceback_string}"
def call_within_event_loop(fn, *args, **kwargs):
"""
Execute a function within a new event loop.
Cartography needs a running event loop, so assuming there is none
(Celery task or even regular DRF endpoint), this creates a new one
and sets it as the current event loop for this thread.
"""
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return fn(*args, **kwargs)
finally:
try:
loop.run_until_complete(loop.shutdown_asyncgens())
except Exception as e:
logger.warning(f"Failed to shutdown async generators cleanly: {e}")
loop.close()
asyncio.set_event_loop(None)
+12 -6
View File
@@ -13,7 +13,6 @@ from api.models import (
ScanSummary,
Tenant,
)
from tasks.jobs.attack_paths.db_utils import get_provider_graph_database_names
logger = get_task_logger(__name__)
@@ -33,13 +32,13 @@ def delete_provider(tenant_id: str, pk: str):
Raises:
Provider.DoesNotExist: If no instance with the provided primary key exists.
"""
# Delete the Attack Paths' graph databases related to the provider
graph_database_names = get_provider_graph_database_names(tenant_id, pk)
# Delete the Attack Paths' graph data related to the provider
tenant_database_name = graph_database.get_database_name(tenant_id)
try:
for graph_database_name in graph_database_names:
graph_database.drop_database(graph_database_name)
graph_database.drop_subgraph(tenant_database_name, str(pk))
except graph_database.GraphDatabaseQueryException as gdb_error:
logger.error(f"Error deleting Provider databases: {gdb_error}")
logger.error(f"Error deleting Provider graph data: {gdb_error}")
raise
# Get all provider related data and delete them in batches
@@ -90,6 +89,13 @@ def delete_tenant(pk: str):
summary = delete_provider(pk, provider.id)
deletion_summary.update(summary)
try:
tenant_database_name = graph_database.get_database_name(pk)
graph_database.drop_database(tenant_database_name)
except graph_database.GraphDatabaseQueryException as gdb_error:
logger.error(f"Error dropping Tenant graph database: {gdb_error}")
raise
Tenant.objects.using(MainRouter.admin_db).filter(id=pk).delete()
return deletion_summary
+14
View File
@@ -35,6 +35,11 @@ from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS
from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS
from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS
from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA
from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA
from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA
from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS
from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS
from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS
@@ -90,6 +95,7 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS),
(lambda name: name == "ccc_aws", CCC_AWS),
(lambda name: name.startswith("c5_"), AWSC5),
(lambda name: name.startswith("csa_"), AWSCSA),
],
"azure": [
(lambda name: name.startswith("cis_"), AzureCIS),
@@ -99,6 +105,7 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name == "ccc_azure", CCC_Azure),
(lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure),
(lambda name: name == "c5_azure", AzureC5),
(lambda name: name.startswith("csa_"), AzureCSA),
],
"gcp": [
(lambda name: name.startswith("cis_"), GCPCIS),
@@ -108,6 +115,7 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP),
(lambda name: name == "ccc_gcp", CCC_GCP),
(lambda name: name == "c5_gcp", GCPC5),
(lambda name: name.startswith("csa_"), GCPCSA),
],
"kubernetes": [
(lambda name: name.startswith("cis_"), KubernetesCIS),
@@ -129,11 +137,17 @@ COMPLIANCE_CLASS_MAP = {
# IaC provider doesn't have specific compliance frameworks yet
# Trivy handles its own compliance checks
],
"image": [
# Image provider doesn't have specific compliance frameworks yet
# Trivy handles its own compliance checks
],
"oraclecloud": [
(lambda name: name.startswith("cis_"), OracleCloudCIS),
(lambda name: name.startswith("csa_"), OracleCloudCSA),
],
"alibabacloud": [
(lambda name: name.startswith("cis_"), AlibabaCloudCIS),
(lambda name: name.startswith("csa_"), AlibabaCloudCSA),
(
lambda name: name == "prowler_threatscore_alibabacloud",
ProwlerThreatScoreAlibaba,
+35 -17
View File
@@ -133,13 +133,41 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
scan_id (str): The ID of the scan that was performed.
provider_id (str): The primary key of the Provider instance that was scanned.
"""
chain(
create_compliance_requirements_task.si(tenant_id=tenant_id, scan_id=scan_id),
update_provider_compliance_scores_task.si(tenant_id=tenant_id, scan_id=scan_id),
).apply_async()
aggregate_attack_surface_task.apply_async(
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
with rls_transaction(tenant_id):
provider_type = Provider.objects.get(id=provider_id).provider
has_compliance = provider_type not in (
Provider.ProviderChoices.IAC.value,
Provider.ProviderChoices.IMAGE.value,
)
if has_compliance:
chain(
create_compliance_requirements_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
update_provider_compliance_scores_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
).apply_async()
aggregate_attack_surface_task.apply_async(
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
)
final_group_tasks = [
check_integrations_task.si(
tenant_id=tenant_id,
provider_id=provider_id,
scan_id=scan_id,
),
]
if has_compliance:
final_group_tasks.append(
generate_compliance_reports_task.si(
tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id
),
)
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
@@ -148,17 +176,7 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
),
),
group(
# Use optimized task that generates both reports with shared queries
generate_compliance_reports_task.si(
tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id
),
check_integrations_task.si(
tenant_id=tenant_id,
provider_id=provider_id,
scan_id=scan_id,
),
),
group(*final_group_tasks),
).apply_async()
if can_provider_run_attack_paths_scan(tenant_id, provider_id):
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
import pytest
from tasks.jobs.attack_paths import prowler as prowler_module
from tasks.jobs.attack_paths import findings as findings_module
from tasks.jobs.attack_paths.scan import run as attack_paths_run
from api.models import (
@@ -21,7 +21,65 @@ from prowler.lib.check.models import Severity
@pytest.mark.django_db
class TestAttackPathsRun:
def test_run_success_flow(self, tenants_fixture, providers_fixture, scans_fixture):
# Patching with decorators as we got a `SyntaxError: too many statically nested blocks` error if we use context managers
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_database")
@patch(
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
)
@patch(
"tasks.jobs.attack_paths.scan.db_utils.get_old_attack_paths_scans",
return_value=[],
)
@patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan")
@patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress")
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
@patch("tasks.jobs.attack_paths.scan.sync.sync_graph")
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph")
@patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes")
@patch("tasks.jobs.attack_paths.scan.findings.analysis")
@patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_ontology.run")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
@patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
"tasks.jobs.attack_paths.scan.graph_database.get_uri",
return_value="bolt://neo4j",
)
@patch(
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
return_value=MagicMock(_enabled_regions=["us-east-1"]),
)
@patch(
"tasks.jobs.attack_paths.scan.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
)
def test_run_success_flow(
self,
mock_init_provider,
mock_get_uri,
mock_create_db,
mock_clear_cache,
mock_cartography_indexes,
mock_cartography_analysis,
mock_cartography_ontology,
mock_findings_indexes,
mock_findings_analysis,
mock_sync_indexes,
mock_drop_subgraph,
mock_sync,
mock_starting,
mock_update_progress,
mock_finish,
mock_get_old_scans,
mock_event_loop,
mock_drop_db,
tenants_fixture,
providers_fixture,
scans_fixture,
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
@@ -45,66 +103,22 @@ class TestAttackPathsRun:
ingestion_fn = MagicMock(return_value=ingestion_result)
with (
patch(
"tasks.jobs.attack_paths.scan.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
),
patch(
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
return_value=MagicMock(_enabled_regions=["us-east-1"]),
),
patch(
"tasks.jobs.attack_paths.scan.graph_database.get_uri",
return_value="bolt://neo4j",
),
patch(
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
return_value="db-scan-id",
side_effect=["db-scan-id", "tenant-db"],
) as mock_get_db_name,
patch(
"tasks.jobs.attack_paths.scan.graph_database.create_database"
) as mock_create_db,
patch(
"tasks.jobs.attack_paths.scan.graph_database.get_session",
return_value=session_ctx,
) as mock_get_session,
patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache"),
patch(
"tasks.jobs.attack_paths.scan.cartography_create_indexes.run"
) as mock_cartography_indexes,
patch(
"tasks.jobs.attack_paths.scan.cartography_analysis.run"
) as mock_cartography_analysis,
patch(
"tasks.jobs.attack_paths.scan.cartography_ontology.run"
) as mock_cartography_ontology,
patch(
"tasks.jobs.attack_paths.scan.prowler.create_indexes"
) as mock_prowler_indexes,
patch(
"tasks.jobs.attack_paths.scan.prowler.analysis"
) as mock_prowler_analysis,
patch(
"tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
return_value=attack_paths_scan,
) as mock_retrieve_scan,
patch(
"tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"
) as mock_starting,
patch(
"tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress"
) as mock_update_progress,
patch(
"tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan"
) as mock_finish,
patch(
"tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
return_value=ingestion_fn,
) as mock_get_ingestion,
patch(
"tasks.jobs.attack_paths.scan._call_within_event_loop",
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
) as mock_event_loop,
):
result = attack_paths_run(str(tenant.id), str(scan.id), "task-123")
@@ -112,29 +126,40 @@ class TestAttackPathsRun:
mock_retrieve_scan.assert_called_once_with(str(tenant.id), str(scan.id))
mock_starting.assert_called_once()
config = mock_starting.call_args[0][2]
assert config.neo4j_database == "db-scan-id"
assert config.neo4j_database == "tenant-db"
mock_get_db_name.assert_has_calls(
[call(attack_paths_scan.id, temporary=True), call(provider.tenant_id)]
)
mock_create_db.assert_called_once_with("db-scan-id")
mock_get_session.assert_called_once_with("db-scan-id")
mock_cartography_indexes.assert_called_once_with(mock_session, config)
mock_prowler_indexes.assert_called_once_with(mock_session)
mock_cartography_analysis.assert_called_once_with(mock_session, config)
mock_cartography_ontology.assert_called_once_with(mock_session, config)
mock_prowler_analysis.assert_called_once_with(
mock_session,
provider,
str(scan.id),
config,
mock_create_db.assert_has_calls([call("db-scan-id"), call("tenant-db")])
mock_get_session.assert_has_calls([call("db-scan-id"), call("tenant-db")])
assert mock_cartography_indexes.call_count == 2
mock_findings_indexes.assert_has_calls([call(mock_session), call(mock_session)])
mock_sync_indexes.assert_called_once_with(mock_session)
# These use tmp_cartography_config (neo4j_database="db-scan-id")
mock_cartography_analysis.assert_called_once()
mock_cartography_ontology.assert_called_once()
mock_findings_analysis.assert_called_once()
mock_drop_subgraph.assert_called_once_with(
database="tenant-db",
provider_id=str(provider.id),
)
mock_sync.assert_called_once_with(
source_database="db-scan-id",
target_database="tenant-db",
provider_id=str(provider.id),
)
mock_get_ingestion.assert_called_once_with(provider.provider)
mock_event_loop.assert_called_once()
mock_update_progress.assert_any_call(attack_paths_scan, 1)
mock_update_progress.assert_any_call(attack_paths_scan, 2)
mock_update_progress.assert_any_call(attack_paths_scan, 95)
mock_update_progress.assert_any_call(attack_paths_scan, 97)
mock_update_progress.assert_any_call(attack_paths_scan, 98)
mock_update_progress.assert_any_call(attack_paths_scan, 99)
mock_finish.assert_called_once_with(
attack_paths_scan, StateChoices.COMPLETED, ingestion_result
)
mock_get_db_name.assert_called_once_with(attack_paths_scan.id)
def test_run_failure_marks_scan_failed(
self, tenants_fixture, providers_fixture, scans_fixture
@@ -181,8 +206,8 @@ class TestAttackPathsRun:
),
patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"),
patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"),
patch("tasks.jobs.attack_paths.scan.prowler.create_indexes"),
patch("tasks.jobs.attack_paths.scan.prowler.analysis"),
patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes"),
patch("tasks.jobs.attack_paths.scan.findings.analysis"),
patch(
"tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
return_value=attack_paths_scan,
@@ -194,12 +219,13 @@ class TestAttackPathsRun:
patch(
"tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan"
) as mock_finish,
patch("tasks.jobs.attack_paths.scan.graph_database.drop_database"),
patch(
"tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
return_value=ingestion_fn,
),
patch(
"tasks.jobs.attack_paths.scan._call_within_event_loop",
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
),
patch(
@@ -261,15 +287,17 @@ class TestAttackPathsRun:
@pytest.mark.django_db
class TestAttackPathsProwlerHelpers:
def test_create_indexes_executes_all_statements(self):
class TestAttackPathsFindingsHelpers:
def test_create_findings_indexes_executes_all_statements(self):
mock_session = MagicMock()
with patch("tasks.jobs.attack_paths.prowler.run_write_query") as mock_run_write:
prowler_module.create_indexes(mock_session)
with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write:
findings_module.create_findings_indexes(mock_session)
assert mock_run_write.call_count == len(prowler_module.INDEX_STATEMENTS)
from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS
assert mock_run_write.call_count == len(FINDINGS_INDEX_STATEMENTS)
mock_run_write.assert_has_calls(
[call(mock_session, stmt) for stmt in prowler_module.INDEX_STATEMENTS]
[call(mock_session, stmt) for stmt in FINDINGS_INDEX_STATEMENTS]
)
def test_load_findings_batches_requests(self, providers_fixture):
@@ -277,25 +305,35 @@ class TestAttackPathsProwlerHelpers:
provider.provider = Provider.ProviderChoices.AWS
provider.save()
# Create a generator that yields two batches
# Create mock Finding objects with to_dict() method
mock_finding_1 = MagicMock()
mock_finding_1.to_dict.return_value = {"id": "1", "resource_uid": "r-1"}
mock_finding_2 = MagicMock()
mock_finding_2.to_dict.return_value = {"id": "2", "resource_uid": "r-2"}
# Create a generator that yields two batches of Finding instances
def findings_generator():
yield [{"id": "1", "resource_uid": "r-1"}]
yield [{"id": "2", "resource_uid": "r-2"}]
yield [mock_finding_1]
yield [mock_finding_2]
config = SimpleNamespace(update_tag=12345)
mock_session = MagicMock()
with (
patch(
"tasks.jobs.attack_paths.prowler.get_root_node_label",
"tasks.jobs.attack_paths.findings.get_root_node_label",
return_value="AWSAccount",
),
patch(
"tasks.jobs.attack_paths.prowler.get_node_uid_field",
"tasks.jobs.attack_paths.findings.get_node_uid_field",
return_value="arn",
),
patch(
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
return_value="AWSResource",
),
):
prowler_module.load_findings(
findings_module.load_findings(
mock_session, findings_generator(), provider, config
)
@@ -317,14 +355,14 @@ class TestAttackPathsProwlerHelpers:
second_batch.single.return_value = {"deleted_findings_count": 0}
mock_session.run.side_effect = [first_batch, second_batch]
prowler_module.cleanup_findings(mock_session, provider, config)
findings_module.cleanup_findings(mock_session, provider, config)
assert mock_session.run.call_count == 2
params = mock_session.run.call_args.args[1]
assert params["provider_uid"] == str(provider.uid)
assert params["last_updated"] == config.update_tag
def test_get_provider_last_scan_findings_returns_latest_scan_data(
def test_stream_findings_with_resources_returns_latest_scan_data(
self,
tenants_fixture,
providers_fixture,
@@ -402,15 +440,18 @@ class TestAttackPathsProwlerHelpers:
latest_scan.refresh_from_db()
with patch(
"tasks.jobs.attack_paths.prowler.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
), patch(
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
"default",
with (
patch(
"tasks.jobs.attack_paths.findings.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
),
patch(
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
"default",
),
):
# Generator yields batches, collect all findings from all batches
findings_batches = prowler_module.get_provider_last_scan_findings(
findings_batches = findings_module.stream_findings_with_resources(
provider,
str(latest_scan.id),
)
@@ -419,18 +460,18 @@ class TestAttackPathsProwlerHelpers:
findings_data.extend(batch)
assert len(findings_data) == 1
finding_dict = findings_data[0]
assert finding_dict["id"] == str(finding.id)
assert finding_dict["resource_uid"] == resource.uid
assert finding_dict["check_title"] == "Check title"
assert finding_dict["scan_id"] == str(latest_scan.id)
finding_result = findings_data[0]
assert finding_result.id == str(finding.id)
assert finding_result.resource_uid == resource.uid
assert finding_result.check_title == "Check title"
assert finding_result.scan_id == str(latest_scan.id)
def test_enrich_and_flatten_batch_single_resource(
def test_enrich_batch_with_resources_single_resource(
self,
tenants_fixture,
providers_fixture,
):
"""One finding + one resource = one output dict"""
"""One finding + one resource = one output Finding instance"""
tenant = tenants_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
@@ -493,25 +534,27 @@ class TestAttackPathsProwlerHelpers:
"muted_reason": finding.muted_reason,
}
# _enrich_and_flatten_batch queries ResourceFindingMapping directly
# _enrich_batch_with_resources queries ResourceFindingMapping directly
# No RLS mock needed - test DB doesn't enforce RLS policies
with patch(
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
"default",
):
result = prowler_module._enrich_and_flatten_batch([finding_dict])
result = findings_module._enrich_batch_with_resources(
[finding_dict], str(tenant.id)
)
assert len(result) == 1
assert result[0]["resource_uid"] == resource.uid
assert result[0]["id"] == str(finding.id)
assert result[0]["status"] == "FAIL"
assert result[0].resource_uid == resource.uid
assert result[0].id == str(finding.id)
assert result[0].status == "FAIL"
def test_enrich_and_flatten_batch_multiple_resources(
def test_enrich_batch_with_resources_multiple_resources(
self,
tenants_fixture,
providers_fixture,
):
"""One finding + three resources = three output dicts"""
"""One finding + three resources = three output Finding instances"""
tenant = tenants_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
@@ -579,24 +622,26 @@ class TestAttackPathsProwlerHelpers:
"muted_reason": finding.muted_reason,
}
# _enrich_and_flatten_batch queries ResourceFindingMapping directly
# _enrich_batch_with_resources queries ResourceFindingMapping directly
# No RLS mock needed - test DB doesn't enforce RLS policies
with patch(
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
"default",
):
result = prowler_module._enrich_and_flatten_batch([finding_dict])
result = findings_module._enrich_batch_with_resources(
[finding_dict], str(tenant.id)
)
assert len(result) == 3
result_resource_uids = {r["resource_uid"] for r in result}
result_resource_uids = {r.resource_uid for r in result}
assert result_resource_uids == {r.uid for r in resources}
# All should have same finding data
for r in result:
assert r["id"] == str(finding.id)
assert r["status"] == "FAIL"
assert r.id == str(finding.id)
assert r.status == "FAIL"
def test_enrich_and_flatten_batch_no_resources_skips(
def test_enrich_batch_with_resources_no_resources_skips(
self,
tenants_fixture,
providers_fixture,
@@ -652,12 +697,14 @@ class TestAttackPathsProwlerHelpers:
# Mock logger to verify no warning is emitted
with (
patch(
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
"default",
),
patch("tasks.jobs.attack_paths.prowler.logger") as mock_logger,
patch("tasks.jobs.attack_paths.findings.logger") as mock_logger,
):
result = prowler_module._enrich_and_flatten_batch([finding_dict])
result = findings_module._enrich_batch_with_resources(
[finding_dict], str(tenant.id)
)
assert len(result) == 0
mock_logger.warning.assert_not_called()
@@ -670,11 +717,11 @@ class TestAttackPathsProwlerHelpers:
scan_id = "some-scan-id"
with (
patch("tasks.jobs.attack_paths.prowler.rls_transaction") as mock_rls,
patch("tasks.jobs.attack_paths.prowler.Finding") as mock_finding,
patch("tasks.jobs.attack_paths.findings.rls_transaction") as mock_rls,
patch("tasks.jobs.attack_paths.findings.Finding") as mock_finding,
):
# Create generator but don't iterate
prowler_module.get_provider_last_scan_findings(provider, scan_id)
findings_module.stream_findings_with_resources(provider, scan_id)
# Nothing should be called yet
mock_rls.assert_not_called()
@@ -695,14 +742,18 @@ class TestAttackPathsProwlerHelpers:
with (
patch(
"tasks.jobs.attack_paths.prowler.get_root_node_label",
"tasks.jobs.attack_paths.findings.get_root_node_label",
return_value="AWSAccount",
),
patch(
"tasks.jobs.attack_paths.prowler.get_node_uid_field",
"tasks.jobs.attack_paths.findings.get_node_uid_field",
return_value="arn",
),
patch(
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
return_value="AWSResource",
),
):
prowler_module.load_findings(mock_session, empty_gen(), provider, config)
findings_module.load_findings(mock_session, empty_gen(), provider, config)
mock_session.run.assert_not_called()
+70 -56
View File
@@ -11,14 +11,15 @@ from tasks.jobs.deletion import delete_provider, delete_tenant
@pytest.mark.django_db
class TestDeleteProvider:
def test_delete_provider_success(self, providers_fixture):
with patch(
"tasks.jobs.deletion.get_provider_graph_database_names"
) as mock_get_provider_graph_database_names, patch(
"tasks.jobs.deletion.graph_database.drop_database"
) as mock_drop_database:
graph_db_names = ["graph-db-1", "graph-db-2"]
mock_get_provider_graph_database_names.return_value = graph_db_names
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
) as mock_get_database_name,
patch(
"tasks.jobs.deletion.graph_database.drop_subgraph"
) as mock_drop_subgraph,
):
instance = providers_fixture[0]
tenant_id = str(instance.tenant_id)
result = delete_provider(tenant_id, instance.id)
@@ -27,33 +28,32 @@ class TestDeleteProvider:
with pytest.raises(ObjectDoesNotExist):
Provider.objects.get(pk=instance.id)
mock_get_provider_graph_database_names.assert_called_once_with(
tenant_id, instance.id
)
mock_drop_database.assert_has_calls(
[call(graph_db_name) for graph_db_name in graph_db_names]
mock_get_database_name.assert_called_once_with(tenant_id)
mock_drop_subgraph.assert_called_once_with(
"tenant-db",
str(instance.id),
)
def test_delete_provider_does_not_exist(self, tenants_fixture):
with patch(
"tasks.jobs.deletion.get_provider_graph_database_names"
) as mock_get_provider_graph_database_names, patch(
"tasks.jobs.deletion.graph_database.drop_database"
) as mock_drop_database:
graph_db_names = ["graph-db-1"]
mock_get_provider_graph_database_names.return_value = graph_db_names
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
) as mock_get_database_name,
patch(
"tasks.jobs.deletion.graph_database.drop_subgraph"
) as mock_drop_subgraph,
):
tenant_id = str(tenants_fixture[0].id)
non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645"
with pytest.raises(ObjectDoesNotExist):
delete_provider(tenant_id, non_existent_pk)
mock_get_provider_graph_database_names.assert_called_once_with(
tenant_id, non_existent_pk
)
mock_drop_database.assert_has_calls(
[call(graph_db_name) for graph_db_name in graph_db_names]
mock_get_database_name.assert_called_once_with(tenant_id)
mock_drop_subgraph.assert_called_once_with(
"tenant-db",
non_existent_pk,
)
@@ -63,21 +63,21 @@ class TestDeleteTenant:
"""
Test successful deletion of a tenant and its related data.
"""
with patch(
"tasks.jobs.deletion.get_provider_graph_database_names"
) as mock_get_provider_graph_database_names, patch(
"tasks.jobs.deletion.graph_database.drop_database"
) as mock_drop_database:
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
) as mock_get_database_name,
patch(
"tasks.jobs.deletion.graph_database.drop_subgraph"
) as mock_drop_subgraph,
patch(
"tasks.jobs.deletion.graph_database.drop_database"
) as mock_drop_database,
):
tenant = tenants_fixture[0]
providers = list(Provider.objects.filter(tenant_id=tenant.id))
graph_db_names_per_provider = [
[f"graph-db-{provider.id}"] for provider in providers
]
mock_get_provider_graph_database_names.side_effect = (
graph_db_names_per_provider
)
# Ensure the tenant and related providers exist before deletion
assert Tenant.objects.filter(id=tenant.id).exists()
assert providers
@@ -89,30 +89,42 @@ class TestDeleteTenant:
assert not Tenant.objects.filter(id=tenant.id).exists()
assert not Provider.objects.filter(tenant_id=tenant.id).exists()
expected_calls = [
call(provider.tenant_id, provider.id) for provider in providers
# get_database_name is called once per provider + once for drop_database
expected_get_db_calls = [call(tenant.id) for _ in providers] + [
call(tenant.id)
]
mock_get_provider_graph_database_names.assert_has_calls(
expected_calls, any_order=True
mock_get_database_name.assert_has_calls(
expected_get_db_calls, any_order=True
)
assert mock_get_provider_graph_database_names.call_count == len(
expected_calls
)
expected_drop_calls = [
call(graph_db_name[0]) for graph_db_name in graph_db_names_per_provider
assert mock_get_database_name.call_count == len(expected_get_db_calls)
expected_drop_subgraph_calls = [
call("tenant-db", str(provider.id)) for provider in providers
]
mock_drop_database.assert_has_calls(expected_drop_calls, any_order=True)
assert mock_drop_database.call_count == len(expected_drop_calls)
mock_drop_subgraph.assert_has_calls(
expected_drop_subgraph_calls,
any_order=True,
)
assert mock_drop_subgraph.call_count == len(expected_drop_subgraph_calls)
mock_drop_database.assert_called_once_with("tenant-db")
def test_delete_tenant_with_no_providers(self, tenants_fixture):
"""
Test deletion of a tenant with no related providers.
"""
with patch(
"tasks.jobs.deletion.get_provider_graph_database_names"
) as mock_get_provider_graph_database_names, patch(
"tasks.jobs.deletion.graph_database.drop_database"
) as mock_drop_database:
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
) as mock_get_database_name,
patch(
"tasks.jobs.deletion.graph_database.drop_subgraph"
) as mock_drop_subgraph,
patch(
"tasks.jobs.deletion.graph_database.drop_database"
) as mock_drop_database,
):
tenant = tenants_fixture[1] # Assume this tenant has no providers
providers = Provider.objects.filter(tenant_id=tenant.id)
@@ -126,5 +138,7 @@ class TestDeleteTenant:
assert deletion_summary == {} # No providers, so empty summary
assert not Tenant.objects.filter(id=tenant.id).exists()
mock_get_provider_graph_database_names.assert_not_called()
mock_drop_database.assert_not_called()
# get_database_name is called once for drop_database
mock_get_database_name.assert_called_once_with(tenant.id)
mock_drop_subgraph.assert_not_called()
mock_drop_database.assert_called_once_with("tenant-db")
+25
View File
@@ -0,0 +1,25 @@
import warnings
from dashboard.common_methods import get_section_containers_format3
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ID",
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_DESCRIPTION",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
].copy()
return get_section_containers_format3(
aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID"
)
+20 -11
View File
@@ -1119,6 +1119,10 @@ def filter_data(
figure=fig,
config={"displayModeBar": False},
)
pie_3 = dcc.Graph(
figure=fig,
config={"displayModeBar": False},
)
table = dcc.Graph(figure=fig, config={"displayModeBar": False})
else:
@@ -1175,22 +1179,25 @@ def filter_data(
style={"height": "300px", "overflow-y": "auto"},
)
color_bars = [
color_mapping_severity[severity]
for severity in df1["SEVERITY"].value_counts().index
]
figure_bars = go.Figure(
data=[
# Prepare bar chart data only if df1 has FAIL findings
if len(df1) > 0:
color_bars = [
color_mapping_severity[severity]
for severity in df1["SEVERITY"].value_counts().index
]
bar_data = [
go.Bar(
x=df1["SEVERITY"]
.value_counts()
.index, # assign x as the dataframe column 'x'
x=df1["SEVERITY"].value_counts().index,
y=df1["SEVERITY"].value_counts().values,
marker=dict(color=color_bars),
textposition="auto",
)
],
]
else:
bar_data = []
figure_bars = go.Figure(
data=bar_data,
layout=go.Layout(
paper_bgcolor="#FFF",
font=dict(size=12, color="#292524"),
@@ -1560,6 +1567,8 @@ def filter_data(
severity_values,
severity_filter_options,
service_values,
provider_values,
provider_filter_options,
service_filter_options,
table_row_values,
table_row_options,
+2
View File
@@ -155,6 +155,8 @@ If you are using AI assistants to help with your contributions, Prowler provides
- **AGENTS.md Files**: Each component of the Prowler monorepo includes an `AGENTS.md` file that contains specific guidelines for AI agents working on that component. These files provide context about project structure, coding standards, and best practices. When working on a specific component, refer to the relevant `AGENTS.md` file (e.g., `prowler/AGENTS.md`, `ui/AGENTS.md`, `api/AGENTS.md`) to ensure your AI assistant follows the appropriate guidelines.
- **AI Skills System**: The [AI Skills system](/developer-guide/ai-skills) provides on-demand patterns, templates, and best practices for AI agents. Skills help AI assistants understand Prowler's conventions and generate code that aligns with project standards. The skills are located in the `skills/` directory and are registered in the `AGENTS.md` files.
These resources help ensure that AI-assisted contributions maintain consistency with Prowler's codebase and development practices.
### Dependency Management
+10
View File
@@ -35,6 +35,16 @@ Create tests that generate both a passing (`PASS`) and a failing (`FAIL`) result
3. Multi-Resource Evaluations:
Design tests with multiple resources to verify check behavior and ensure the correct number of findings.
## Test File Naming Conventions
Test files follow the pattern `{service}_{check_name}_test.py` for checks and `{service}_service_test.py` for services.
### Duplicate Names Across Providers
When a test file name already exists in another provider, add your provider prefix to avoid conflicts. A GitHub Action will fail if duplicate names are detected.
**Example:** If `kms_service_test.py` already exists in AWS, name your Oracle Cloud test `oraclecloud_kms_service_test.py`.
## Running Prowler Tests
To execute the Prowler test suite, install the necessary dependencies listed in the `pyproject.toml` file.
+7
View File
@@ -267,6 +267,13 @@
"user-guide/providers/oci/getting-started-oci",
"user-guide/providers/oci/authentication"
]
},
{
"group": "OpenStack",
"pages": [
"user-guide/providers/openstack/getting-started-openstack",
"user-guide/providers/openstack/authentication"
]
}
]
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -66,6 +66,11 @@ The following list includes all the AWS checks with configurable variables that
| `secretsmanager_secret_rotated_periodically` | `max_days_secret_unrotated` | Integer |
| `ssm_document_secrets` | `secrets_ignore_patterns` | List of Strings |
| `trustedadvisor_premium_support_plan_subscribed` | `verify_premium_support_plans` | Boolean |
| `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings |
| `eventbridge_bus_cross_account_access` | `trusted_account_ids` | List of Strings |
| `eventbridge_schema_registry_cross_account_access` | `trusted_account_ids` | List of Strings |
| `s3_bucket_cross_account_access` | `trusted_account_ids` | List of Strings |
| `ssm_documents_set_as_public` | `trusted_account_ids` | List of Strings |
| `vpc_endpoint_connections_trust_boundaries` | `trusted_account_ids` | List of Strings |
| `vpc_endpoint_services_allowed_principals_trust_boundaries` | `trusted_account_ids` | List of Strings |
@@ -202,7 +207,10 @@ aws:
]
# AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries)
# AWS SSM Configuration (aws.ssm_documents_set_as_public)
# AWS SSM Configuration (ssm_documents_set_as_public)
# AWS S3 Configuration (s3_bucket_cross_account_access)
# AWS EventBridge Configuration (eventbridge_schema_registry_cross_account_access, eventbridge_bus_cross_account_access)
# AWS DynamoDB Configuration (dynamodb_table_cross_account_access)
# Single account environment: No action required. The AWS account number will be automatically added by the checks.
# Multi account environment: Any additional trusted account number should be added as a space separated list, e.g.
# trusted_account_ids : ["123456789012", "098765432109", "678901234567"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

@@ -27,10 +27,9 @@ These permissions allow Prowler to retrieve metadata from the assumed identity a
Assign the following Microsoft Graph permissions:
- `AuditLog.Read.All`
- `Directory.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All` (optional, for multifactor authentication (MFA) checks)
- `AuditLog.Read.All` (optional, for multifactor authentication (MFA) checks)
<Note>
Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permissions. Note that Entra checks related to DirectoryRoles and GetUsers will not run with this permission.
@@ -49,22 +48,22 @@ Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permiss
3. Search and select:
- `AuditLog.Read.All`
- `Directory.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All`
- `AuditLog.Read.All`
![Permission Screenshots](/images/providers/domain-permission.png)
4. Click "Add permissions", then grant admin consent
![Grant Admin Consent](/images/providers/grant-admin-consent.png)
![Granted Admin Consent](/images/providers/granted-admin-consent.png)
</Tab>
<Tab title="Azure CLI">
1. To grant permissions to a Service Principal, execute the following command in a terminal:
```console
az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role 38d9df27-64da-44fd-b7c5-a6fbac20248f=Role b0afded3-3588-46d8-8b3d-9842eff778da=Role
az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role b0afded3-3588-46d8-8b3d-9842eff778da=Role
```
</Tab>
</Tabs>
@@ -84,17 +83,17 @@ By default, Prowler scans all accessible subscriptions. If you need to audit spe
1. To grant Prowler access to scan a specific Azure subscription, follow these steps in Azure Portal:
Navigate to the subscription you want to audit with Prowler.
1. In the left menu, select "Access control (IAM)".
2. In the left menu, select "Access control (IAM)".
2. Click "+ Add" and select "Add role assignment".
3. Click "+ Add" and select "Add role assignment".
3. In the search bar, enter `Reader`, select it and click "Next".
4. In the search bar, enter `Reader`, select it and click "Next".
4. In the "Members" tab, click "+ Select members", then add the accounts to assign this role.
5. In the "Members" tab, click "+ Select members", then add the accounts to assign this role.
5. Click "Review + assign" to finalize and apply the role assignment.
6. Click "Review + assign" to finalize and apply the role assignment.
![Adding the Reader Role to a Subscription](/images/providers/add-reader-role.gif)
![Adding the Reader Role to a Subscription](/images/providers/add-reader-role.png)
</Tab>
<Tab title="Azure CLI">
1. Open a terminal and execute the following command to assign the `Reader` role to the identity that is going to be assumed by Prowler:
@@ -377,7 +376,7 @@ The ProwlerRole is a custom role required for specific security checks. First, c
#### Step 4: (Optional) Assign Microsoft Graph Permissions
For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and optionally `UserAuthenticationMethod.Read.All` and `AuditLog.Read.All`.
For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and `AuditLog.Read.All`.
<Note>
Assigning Microsoft Graph API permissions to a Managed Identity requires Azure CLI or PowerShell - it cannot be done through the Azure Portal's standard role assignment interface.
@@ -1,5 +1,5 @@
---
title: 'Cloudflare Authentication'
title: 'Cloudflare Authentication in Prowler'
---
Prowler for Cloudflare supports the following authentication methods:
@@ -87,6 +87,30 @@ You can also use zone IDs instead of domain names:
prowler cloudflare -f 023e105f4ecef8ad9ca31a8372d0c353
```
## Filtering Accounts
By default, Prowler scans all accounts accessible with your credentials. If your API Token or API Key has access to multiple Cloudflare accounts, you can restrict the scan to specific accounts using the `--account-id` argument:
```bash
prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59
```
You can specify multiple account IDs:
```bash
prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 9a7806061c88ada191ed06f989cc3dac
```
<Note>
If any of the provided account IDs are not found among the accounts accessible with your credentials, Prowler will raise an error and stop execution.
</Note>
You can combine account and zone filtering to narrow the scan scope further:
```bash
prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 -f example.com
```
## Configuration
Prowler uses a configuration file to customize provider behavior. The Cloudflare configuration includes:
@@ -1,5 +1,5 @@
---
title: 'MongoDB Atlas Authentication'
title: 'MongoDB Atlas Authentication in Prowler'
---
MongoDB Atlas provider uses [HTTP Digest Authentication with API key pairs consisting of a public key and private key](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-service).
@@ -1,5 +1,5 @@
---
title: 'Oracle Cloud Infrastructure (OCI) Authentication'
title: 'Oracle Cloud Infrastructure (OCI) Authentication in Prowler'
---
This guide covers all authentication methods supported by Prowler for Oracle Cloud Infrastructure (OCI).
@@ -0,0 +1,536 @@
---
title: 'OpenStack Authentication in Prowler'
---
<Warning>
Prowler currently supports **public cloud OpenStack providers** (OVH, Infomaniak, Vexxhost, etc.). Support for self-deployed OpenStack environments is not yet available and will be added in future releases.
</Warning>
This guide shows how to obtain OpenStack credentials and configure Prowler to scan your OpenStack infrastructure using the recommended `clouds.yaml` authentication method.
## Quick Start: Getting Your OpenStack Credentials
<Tabs>
<Tab title="OVH">
### Step 1: Create an OpenStack User with Reader Role
Before using Prowler, create a dedicated user in your OVH Public Cloud account:
1. Log into the [OVH Control Panel](https://www.ovh.com/manager/)
2. Navigate to "Public Cloud" → Select your project
3. Click "Users & Roles" in the left sidebar
![OVH Users & Roles](./images/users.png)
4. Click "Add User"
5. Enter a user description (e.g., `Prowler Audit User`)
6. Assign the "Infrastructure Supervisor" role (this is the reader role) or specific read-only operator roles (if needed to audit only specific services)
![OVH Select Roles](./images/roles.png)
7. Click "Generate" to create the user
8. Copy the password and store it securely
<Warning>
Avoid using administrator or member roles for security auditing. Reader or operator roles provide sufficient access for Prowler while maintaining security best practices.
</Warning>
### Step 2: Access the Horizon Dashboard
1. From the OVH Control Panel, go to "Public Cloud" → Your project
2. Click "Horizon" in the left sidebar (or access the Horizon URL provided by OVH)
![OVH Horizon](./images/horizon.png)
3. Log in with the user credentials created in Step 1. Ensure the correct user is selected; logging in with the root user will download root user credentials. If the wrong user is logged in, log out and log in again with the correct user.
### Step 3: Navigate to API Access
Once logged into Horizon:
1. In the left sidebar, click "Project"
2. Navigate to "API Access"
![OVH API Access](./images/api-access.png)
3. You'll see the API Access page with information about your OpenStack endpoints
### Step 4: Download the clouds.yaml File
The `clouds.yaml` file contains all necessary credentials in the correct format for Prowler:
1. On the API Access page, look for the "Download OpenStack RC File" dropdown button
2. Click the dropdown and select "OpenStack clouds.yaml File"
![OVH Download RC File](./images/download-yaml.png)
3. The file will be downloaded to your computer
<Note>
The clouds.yaml file contains your password in plain text. Ensure you store it securely with appropriate file permissions (see [Security Best Practices](#security-best-practices) below).
</Note>
### Step 5: Configure clouds.yaml for Prowler
Save the file to the default OpenStack configuration directory:
```bash
# Create the directory if it doesn't exist
mkdir -p ~/.config/openstack
# Move or copy the downloaded clouds.yaml file
mv ~/Downloads/clouds.yaml ~/.config/openstack/clouds.yaml
# Set secure file permissions
chmod 600 ~/.config/openstack/clouds.yaml
```
The downloaded file will look similar to this:
```yaml
clouds:
openstack:
auth:
auth_url: https://auth.cloud.ovh.net/v3
username: user-xxxxxxxxxx
password: your-password-here
project_id: your-project-id
project_name: your-project-name
user_domain_name: Default
project_domain_name: Default
region_name: GRA7
interface: public
identity_api_version: 3
```
You can customize the cloud name (e.g., change `openstack` to `ovh-production`):
```yaml
clouds:
ovh-production:
auth:
auth_url: https://auth.cloud.ovh.net/v3
username: user-xxxxxxxxxx
password: your-password-here
project_id: your-project-id
user_domain_name: Default
project_domain_name: Default
region_name: GRA7
identity_api_version: "3"
```
Alternatively, save the file to a custom location and specify the path when running Prowler:
```bash
# Save the clouds.yaml file to a custom location
mv ~/Downloads/clouds.yaml /path/to/my/clouds.yaml
# Set secure file permissions
chmod 600 /path/to/my/clouds.yaml
```
### Step 6: Run Prowler
Now you can scan your OVH OpenStack infrastructure:
**Using the default location:**
```bash
prowler openstack --clouds-yaml-cloud openstack
```
Or if you customized the cloud name:
```bash
prowler openstack --clouds-yaml-cloud ovh-production
```
**Using a custom location:**
```bash
prowler openstack --clouds-yaml-file /path/to/my/clouds.yaml --clouds-yaml-cloud openstack
```
Prowler will authenticate with your OVH OpenStack cloud and begin scanning.
</Tab>
<Tab title="Generic Public Cloud">
### Step 1: Create an OpenStack User with Reader Role
Before using Prowler, create a dedicated user in your OpenStack public cloud account. The exact steps vary by provider (Infomaniak, Vexxhost, Fuga Cloud, etc.), but the general process is:
1. Log into your provider's control panel or management interface
2. Navigate to your OpenStack project or account settings
3. Find the user management section (typically named "Users", "Users & Roles", or "Access Management")
4. Create a new user (e.g., `prowler-audit`)
5. Assign the **Reader** role or equivalent read-only role to the user:
- **Reader**: Standard read-only access to all resources
- **Viewer**: Alternative read-only role (in some deployments)
- Avoid **Member** or **Admin** roles for security auditing
6. Save the credentials (username and password) securely
<Warning>
Avoid using administrator or member roles for security auditing. Reader or Viewer roles provide sufficient access for Prowler while maintaining security best practices.
</Warning>
<Note>
Consult the provider's documentation for specific instructions on creating users and assigning roles. Consider contributing by opening an issue or pull request with instructions for additional providers.
</Note>
### Step 2: Access the Horizon Dashboard
Horizon is the standard OpenStack web interface available across all OpenStack providers:
1. Find the Horizon dashboard link in your provider's control panel
- Look for "OpenStack Dashboard", "Horizon", "Web Console", or similar
2. Access the Horizon URL (typically `https://your-provider-domain/horizon` or similar)
3. Log in with the user credentials created in Step 1
<Note>
The Horizon dashboard interface is standardized across OpenStack providers, though branding and colors may vary. The navigation and functionality remain consistent.
</Note>
### Step 3: Navigate to API Access
Once logged into Horizon:
1. In the left sidebar, click "Project"
2. Navigate to "API Access"
3. You'll see the API Access page with information about your OpenStack endpoints
### Step 4: Download the clouds.yaml File
The `clouds.yaml` file contains all necessary credentials in the correct format for Prowler:
1. On the API Access page, look for the "Download OpenStack RC File" dropdown button
2. Click the dropdown and select "OpenStack clouds.yaml File"
3. The file will be downloaded to your computer
<Note>
The clouds.yaml file contains your password in plain text. Ensure you store it securely with appropriate file permissions (see [Security Best Practices](#security-best-practices) below).
</Note>
### Step 5: Configure clouds.yaml for Prowler
Save the file to the default OpenStack configuration directory:
```bash
# Create the directory if it doesn't exist
mkdir -p ~/.config/openstack
# Move or copy the downloaded clouds.yaml file
mv ~/Downloads/clouds.yaml ~/.config/openstack/clouds.yaml
# Set secure file permissions
chmod 600 ~/.config/openstack/clouds.yaml
```
The downloaded file will look similar to this (values will vary by provider):
```yaml
clouds:
openstack:
auth:
auth_url: https://auth.example-cloud.com:5000/v3
username: user-xxxxxxxxxx
password: your-password-here
project_id: your-project-id
project_name: your-project-name
user_domain_name: Default
project_domain_name: Default
region_name: RegionOne
interface: public
identity_api_version: 3
```
You can customize the cloud name (e.g., change `openstack` to `infomaniak-production`):
```yaml
clouds:
infomaniak-production:
auth:
auth_url: https://api.pub1.infomaniak.cloud/identity/v3
username: user-xxxxxxxxxx
password: your-password-here
project_id: your-project-id
user_domain_name: Default
project_domain_name: Default
region_name: dc3-a
identity_api_version: "3"
```
Alternatively, save the file to a custom location and specify the path when running Prowler:
```bash
# Save the clouds.yaml file to a custom location
mv ~/Downloads/clouds.yaml /path/to/my/clouds.yaml
# Set secure file permissions
chmod 600 /path/to/my/clouds.yaml
```
### Step 6: Run Prowler
Now you can scan your OpenStack infrastructure:
**Using the default location:**
```bash
prowler openstack --clouds-yaml-cloud openstack
```
Or if you customized the cloud name:
```bash
prowler openstack --clouds-yaml-cloud infomaniak-production
```
**Using a custom location:**
```bash
prowler openstack --clouds-yaml-file /path/to/my/clouds.yaml --clouds-yaml-cloud openstack
```
Prowler will authenticate with your OpenStack cloud and begin scanning.
</Tab>
</Tabs>
## Managing Multiple OpenStack Environments
To scan multiple OpenStack projects or providers, add multiple cloud configurations to your `clouds.yaml`:
```yaml
clouds:
ovh-production:
auth:
auth_url: https://auth.cloud.ovh.net/v3
username: user-prod
password: prod-password
project_id: prod-project-id
user_domain_name: Default
project_domain_name: Default
region_name: GRA7
identity_api_version: "3"
ovh-staging:
auth:
auth_url: https://auth.cloud.ovh.net/v3
username: user-staging
password: staging-password
project_id: staging-project-id
user_domain_name: Default
project_domain_name: Default
region_name: SBG5
identity_api_version: "3"
infomaniak-production:
auth:
auth_url: https://api.pub1.infomaniak.cloud/identity/v3
username: infomaniak-user
password: infomaniak-password
project_id: infomaniak-project-id
user_domain_name: Default
project_domain_name: Default
region_name: dc3-a
identity_api_version: "3"
```
Then scan each environment separately:
```bash
prowler openstack --clouds-yaml-cloud ovh-production --output-directory ./reports/ovh-prod/
prowler openstack --clouds-yaml-cloud ovh-staging --output-directory ./reports/ovh-staging/
prowler openstack --clouds-yaml-cloud infomaniak-production --output-directory ./reports/infomaniak/
```
## Creating a User With Reader Role
For security auditing, Prowler only needs **read-only access** to your OpenStack resources.
### Understanding OpenStack Roles
OpenStack uses a role-based access control (RBAC) system. Common read-only roles include:
| Role | Access Level | Recommended for Prowler |
|------|--------------|------------------------|
| **Reader** | Read-only access to all resources | ✅ **Recommended** |
| **Viewer** | Read-only access (older deployments) | ✅ **Recommended** |
| **Compute/Network/ObjectStore Operator** | Service-specific read-only access | ✅ **Recommended** (OVH) |
| **Member** | Read and limited write access | ⚠️ Too permissive |
| **Admin** | Full administrative access | ❌ **Not recommended** |
<Warning>
Avoid using administrator or member roles for security auditing. Reader or Viewer roles provide sufficient access for Prowler while maintaining security best practices.
</Warning>
### How to Assign the Reader Role
The process for creating a user with the Reader role is covered in the [Quick Start](#quick-start-getting-your-openstack-credentials) section above. Select your provider's tab (OVH or Generic Public Cloud) for detailed instructions.
### Verifying Read-Only Access
After assigning read-only roles, verify the user cannot make changes:
1. Log into Horizon with the Prowler user credentials
2. Attempt to create or modify a resource (e.g., create an instance)
3. The action should be denied or the UI should show read-only mode
<Note>
Some OpenStack deployments may use custom role names. Consult your OpenStack administrator to identify the appropriate read-only role for your environment.
</Note>
## Alternative Authentication Methods
While `clouds.yaml` is the recommended method, Prowler also supports these alternatives:
### Environment Variables
Set OpenStack credentials as environment variables:
```bash
export OS_AUTH_URL="https://openstack.example.com:5000/v3"
export OS_USERNAME="prowler-audit"
export OS_PASSWORD="your-secure-password"
export OS_PROJECT_ID="your-project-id"
export OS_REGION_NAME="RegionOne"
export OS_IDENTITY_API_VERSION="3"
export OS_USER_DOMAIN_NAME="Default"
export OS_PROJECT_DOMAIN_NAME="Default"
```
Then run Prowler:
```bash
prowler openstack
```
### Command-Line Arguments (Flags)
Pass credentials directly via CLI flags:
```bash
prowler openstack \
--os-auth-url https://openstack.example.com:5000/v3 \
--os-username prowler-audit \
--os-password your-secure-password \
--os-project-id your-project-id \
--os-user-domain-name Default \
--os-project-domain-name Default \
--os-identity-api-version 3
```
<Warning>
Avoid passing passwords via command-line arguments in production environments. Commands may appear in shell history, process listings, or logs. Use `clouds.yaml` or environment variables instead.
</Warning>
## Authentication Priority
When multiple authentication methods are configured, Prowler uses this priority order:
1. **clouds.yaml** (if `--clouds-yaml-file` or `--clouds-yaml-cloud` is provided)
2. **Command-line arguments + Environment variables** (CLI arguments override environment variables)
## Security Best Practices
### File Permissions
Protect your `clouds.yaml` file from unauthorized access:
```bash
# Set read/write for owner only
chmod 600 ~/.config/openstack/clouds.yaml
# Verify permissions
ls -la ~/.config/openstack/clouds.yaml
# Should show: -rw------- (600)
```
### Credential Management
- **Use dedicated audit users**: Create separate OpenStack users specifically for Prowler audits
- **Use read-only roles**: Assign only Reader or Viewer roles to limit access
- **Rotate credentials regularly**: Change passwords and regenerate credentials periodically
- **Use Application Credentials**: For advanced setups, use OpenStack Application Credentials with scoped permissions and expiration dates
- **Avoid hardcoding passwords**: Never commit `clouds.yaml` files with passwords to version control
- **Use secrets managers**: For production environments, consider using tools like HashiCorp Vault or AWS Secrets Manager to store credentials
### Network Security
- **Use HTTPS**: Always connect to OpenStack endpoints via HTTPS
- **Verify SSL certificates**: Avoid using `--insecure` flag in production
- **Restrict network access**: Use firewall rules to limit access to OpenStack APIs
- **Use VPN or private networks**: When possible, run Prowler from within your private network
## Troubleshooting
### "Missing mandatory OpenStack environment variables" Error
This error occurs when required credentials are not configured:
```bash
# Check current environment variables
env | grep OS_
# Verify clouds.yaml exists and is readable
cat ~/.config/openstack/clouds.yaml
```
**Solution**: Ensure all required credentials are configured using one of the authentication methods above.
### "Failed to create OpenStack connection" Error
This error indicates authentication failure. Verify:
- ✅ Auth URL is correct and accessible: `curl -k https://auth-url/v3`
- ✅ Username and password are correct
- ✅ Project ID exists and you have access
- ✅ Network connectivity to the OpenStack endpoint
- ✅ SSL/TLS certificates are valid
**Solution**: Test authentication using the OpenStack CLI:
```bash
openstack --os-cloud openstack server list
```
If this fails, your credentials or network connectivity need attention.
### "Cloud 'name' not found in clouds.yaml" Error
This error occurs when the specified cloud name doesn't exist in `clouds.yaml`:
**Solution**:
- Verify the cloud name matches exactly (case-sensitive)
- Check your `clouds.yaml` file for the correct cloud name:
```bash
cat ~/.config/openstack/clouds.yaml
```
- Ensure proper YAML syntax (use a YAML validator if needed)
### Permission Denied Errors
If specific checks fail due to insufficient permissions:
1. Verify role assignments:
```bash
openstack role assignment list --user prowler-audit --project your-project
```
2. Ensure the user has Reader or Viewer roles
3. Check if specific services require additional permissions (consult your OpenStack administrator)
<Warning>
Using Public Cloud credentials can limit Keystone API access, so the command above may not work. Verify permissions in the provider's control panel instead.
</Warning>
## Next Steps
- [Getting Started with OpenStack](/user-guide/providers/openstack/getting-started-openstack) - Run your first scan
- [Mutelist](/user-guide/cli/tutorials/mutelist) - Suppress known findings and false positives
## Additional Resources
### Provider-Specific Documentation
- **OVH Public Cloud**: [OpenStack Documentation](https://help.ovhcloud.com/csm/en-gb-documentation-public-cloud-cross-functional?id=kb_browse_cat&kb_id=574a8325551974502d4c6e78b7421938&kb_category=32a89dbc81ef5a581e11e4879ea7a52b&spa=1)
### OpenStack References
- [OpenStack Documentation](https://docs.openstack.org/)
- [OpenStack Security Guide](https://docs.openstack.org/security-guide/)
- [clouds.yaml Format](https://docs.openstack.org/python-openstackclient/latest/configuration/index.html)
@@ -0,0 +1,285 @@
---
title: 'Getting Started With OpenStack'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="5.18.0" />
Prowler for OpenStack allows you to audit your OpenStack cloud infrastructure for security misconfigurations, including compute instances, networking, identity and access management, storage, and more.
<Warning>
Prowler currently supports **public cloud OpenStack providers** (OVH, Infomaniak, Vexxhost, etc.). Support for self-deployed OpenStack environments is not yet available, if you are interested in this feature, please [open an issue](https://github.com/prowler-cloud/prowler/issues/new) or [contact us](https://prowler.com/contact).
</Warning>
## Prerequisites
Before running Prowler with the OpenStack provider, ensure you have:
1. An OpenStack public cloud account with at least one project
2. Access to the Horizon dashboard or provider control panel
3. An OpenStack user with the **Reader** role assigned to your project (see detailed instructions in the [Authentication guide](/user-guide/providers/openstack/authentication#creating-a-user-with-reader-role))
4. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli)) or an account created in [Prowler Cloud](https://cloud.prowler.com)
<CardGroup cols={2}>
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
Run OpenStack security audits with Prowler CLI
</Card>
<Card title="Authentication Methods" icon="key" href="/user-guide/providers/openstack/authentication">
Learn about OpenStack authentication options
</Card>
</CardGroup>
## Prowler CLI
### Step 1: Set Up Authentication
Download the `clouds.yaml` file from your OpenStack provider (see [Authentication guide](/user-guide/providers/openstack/authentication) for detailed instructions) and save it to `~/.config/openstack/clouds.yaml`:
```bash
# Create the directory
mkdir -p ~/.config/openstack
# Move the downloaded file
mv ~/Downloads/clouds.yaml ~/.config/openstack/clouds.yaml
# Set secure permissions
chmod 600 ~/.config/openstack/clouds.yaml
```
Prowler supports multiple authentication methods:
**Option 1: Using clouds.yaml (Recommended)**
```bash
# Default location (~/.config/openstack/clouds.yaml)
prowler openstack --clouds-yaml-cloud openstack
# Custom location
prowler openstack --clouds-yaml-file /path/to/clouds.yaml --clouds-yaml-cloud openstack
```
**Option 2: Using Environment Variables**
```bash
export OS_AUTH_URL=https://auth.example.com:5000/v3
export OS_USERNAME=user-xxxxxxxxxx
export OS_PASSWORD=your-password
export OS_PROJECT_ID=your-project-id
export OS_USER_DOMAIN_NAME=Default
export OS_PROJECT_DOMAIN_NAME=Default
export OS_IDENTITY_API_VERSION=3
prowler openstack
```
**Option 3: Using Flags (CLI Arguments)**
```bash
prowler openstack \
--os-auth-url https://auth.example.com:5000/v3 \
--os-username user-xxxxxxxxxx \
--os-password your-password \
--os-project-id your-project-id \
--os-user-domain-name Default \
--os-project-domain-name Default \
--os-identity-api-version 3
```
<Note>
For detailed step-by-step instructions with screenshots, see the [OpenStack Authentication guide](/user-guide/providers/openstack/authentication).
</Note>
### Step 2: Run Your First Scan
Run a baseline scan of your OpenStack cloud:
```bash
prowler openstack --clouds-yaml-cloud openstack
```
Replace `openstack` with your cloud name if you customized it in the `clouds.yaml` file (e.g., `ovh-production`).
Prowler will automatically discover and audit all supported OpenStack services in your project.
**Scan a specific OpenStack service:**
```bash
# Audit only compute (Nova) resources
prowler openstack --services compute
# Audit only networking (Neutron) resources
prowler openstack --services network
# Audit only identity (Keystone) resources
prowler openstack --services identity
```
**Run specific security checks:**
```bash
# Execute specific checks by name
prowler openstack --checks compute_instance_public_ip_associated
# List all available checks
prowler openstack --list-checks
```
**Filter by check severity:**
```bash
# Run only high or critical severity checks
prowler openstack --severity critical high
```
**Generate specific output formats:**
```bash
# JSON only
prowler openstack --output-modes json
# CSV and HTML
prowler openstack --output-modes csv html
# All formats
prowler openstack --output-modes csv json html json-asff
# Custom output directory
prowler openstack --output-directory /path/to/reports/
```
**Scan multiple OpenStack clouds:**
Configure `clouds.yaml` with multiple cloud configurations:
```yaml
clouds:
production:
auth:
auth_url: https://prod.example.com:5000/v3
username: prod-user
password: prod-password
project_id: prod-project-id
region_name: RegionOne
identity_api_version: "3"
staging:
auth:
auth_url: https://staging.example.com:5000/v3
username: staging-user
password: staging-password
project_id: staging-project-id
region_name: RegionOne
identity_api_version: "3"
```
Run audits against each environment:
```bash
prowler openstack --clouds-yaml-cloud production --output-directory ./reports/production/
prowler openstack --clouds-yaml-cloud staging --output-directory ./reports/staging/
```
**Use mutelist to suppress findings:**
Create a mutelist file to suppress known findings:
```yaml
# mutelist.yaml
Mutelist:
Accounts:
"*":
Checks:
compute_instance_public_ip_associated:
Resources:
- "instance-id-1"
- "instance-id-2"
Reason: "Public IPs required for web servers"
```
Run with mutelist:
```bash
prowler openstack --mutelist-file mutelist.yaml
```
### Step 3: Review the Results
Prowler outputs findings to the console and generates reports in multiple formats.
By default, Prowler generates reports in the `output/` directory:
- CSV format: `output/prowler-output-{timestamp}.csv`
- JSON format: `output/prowler-output-{timestamp}.json`
- HTML dashboard: `output/prowler-output-{timestamp}.html`
## Supported OpenStack Services
Prowler currently supports security checks for the following OpenStack services:
| Common Name | OpenStack Service | Description | Example Checks |
|-------------|-------------------|-------------|----------------|
| **Compute** | Nova | Virtual machine instances | Public IP associations, security group usage |
| **Networking** | Neutron | Virtual networks and security | Security group rules, network isolation |
| **Identity** | Keystone | Authentication and authorization | Password policies, MFA configuration |
| **Image** | Glance | Virtual machine images | Image visibility, image encryption |
| **Block Storage** | Cinder | Persistent block storage | Volume encryption, backup policies |
| **Object Storage** | Swift | Object storage service | Container ACLs, public access |
<Note>
Support for additional OpenStack services will be added in future releases. Check the [release notes](https://github.com/prowler-cloud/prowler/releases) for updates.
</Note>
## Troubleshooting
### Authentication Errors
If encountering authentication errors:
1. Verify credentials are correct:
```bash
# Test OpenStack CLI with the same credentials
openstack --os-cloud openstack server list
```
2. Check network connectivity to the authentication endpoint:
```bash
curl https://openstack.example.com:5000/v3
```
3. Verify the Identity API version is v3:
```bash
echo $OS_IDENTITY_API_VERSION
# Should output: 3
```
For detailed troubleshooting, see the [Authentication guide](/user-guide/providers/openstack/authentication#troubleshooting).
### Permission Errors
If checks are failing due to insufficient permissions:
- Ensure your OpenStack user has the **Reader** role assigned to the project
- Check role assignments in your provider's control panel or Horizon dashboard
- Verify that your user has access to all required services (Compute, Networking, Identity, etc.)
- Contact your OpenStack provider support if you need additional permissions
### Keystone/Identity Service Limitations
<Warning>
Public cloud OpenStack providers (OVH, Infomaniak, Vexxhost, etc.) typically **do not expose** the Keystone/Identity service API to customers for security reasons. This means that Identity-related security checks may not be available or may return limited information.
This is expected behavior, not an error. This limitation explains why those checks are not currently available in Prowler.
</Warning>
If you see errors related to the Identity service:
- This is expected behavior for public cloud providers
- Identity-related checks will be added for self-deployed OpenStack environments in future releases
- Focus on other available services (Compute, Networking, Storage, etc.)
## OpenStack Additional Resources
- **Supported OpenStack versions**: Stein (2019.1) and later
- **Minimum Identity API version**: v3
- **Tested providers**: OVH Public Cloud, OpenStack-Ansible, DevStack
- **Cloud compatibility**: Fully compatible with standard OpenStack APIs
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

@@ -14,15 +14,15 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
If the account is created without an invitation, a new tenant will be provisioned for it. However, if the account is created through an invitation, the user will join the inviter’s tenant.
</Note>
## Membership
## Organization
To get to User-Invitation Management we will focus on the Membership section.
To get to User-Invitation Management we will focus on the Organization section.
<Note>
**Only users that have the _Invite and Manage Users_ or _admin_ permission can access this section.**
</Note>
<img src="/images/prowler-app/rbac/membership.png" alt="Membership tab" width="700" />
<img src="/images/prowler-app/rbac/organization.png" alt="Organization tab" width="400" />
### Users
Generated
+256 -12
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -912,19 +912,18 @@ files = [
[[package]]
name = "azure-core"
version = "1.35.0"
version = "1.38.0"
description = "Microsoft Azure Core Library for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1"},
{file = "azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c"},
{file = "azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335"},
{file = "azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993"},
]
[package.dependencies]
requests = ">=2.21.0"
six = ">=1.11.0"
typing-extensions = ">=4.6.0"
[package.extras]
@@ -2122,6 +2121,18 @@ dash = ">=3.0.4"
[package.extras]
pandas = ["numpy (>=2.0.2)", "pandas (>=2.2.3)"]
[[package]]
name = "decorator"
version = "5.2.1"
description = "Decorators for Humans"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"},
{file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"},
]
[[package]]
name = "deprecated"
version = "1.2.18"
@@ -2244,6 +2255,60 @@ docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"]
ssh = ["paramiko (>=2.4.3)"]
websockets = ["websocket-client (>=1.3.0)"]
[[package]]
name = "dogpile-cache"
version = "1.4.1"
description = "A caching front-end based on the Dogpile lock."
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "python_version < \"3.10\""
files = [
{file = "dogpile_cache-1.4.1-py3-none-any.whl", hash = "sha256:99130ce990800c8d89c26a5a8d9923cbe1b78c8a9972c2aaa0abf3d2ef2984ad"},
{file = "dogpile_cache-1.4.1.tar.gz", hash = "sha256:e25c60e677a5e28ff86124765fbf18c53257bcd7830749cd5ba350ace2a12989"},
]
[package.dependencies]
decorator = ">=4.0.0"
stevedore = ">=3.0.0"
typing_extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
[package.extras]
bmemcached = ["python-binary-memcached"]
memcached = ["python-memcached"]
pifpaf = ["pifpaf (>=3.2.0)"]
pylibmc = ["pylibmc"]
pymemcache = ["pymemcache"]
redis = ["redis"]
valkey = ["valkey"]
[[package]]
name = "dogpile-cache"
version = "1.5.0"
description = "A caching front-end based on the Dogpile lock."
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "python_version >= \"3.10\""
files = [
{file = "dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d"},
{file = "dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec"},
]
[package.dependencies]
decorator = ">=4.0.0"
stevedore = ">=3.0.0"
typing_extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
[package.extras]
bmemcached = ["python-binary-memcached"]
memcached = ["python-memcached"]
pifpaf = ["pifpaf (>=3.3.0)"]
pylibmc = ["pylibmc"]
pymemcache = ["pymemcache"]
redis = ["redis"]
valkey = ["valkey"]
[[package]]
name = "dparse"
version = "0.6.4"
@@ -2903,6 +2968,18 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "iso8601"
version = "2.1.0"
description = "Simple module to parse ISO 8601 dates"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"},
{file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"},
]
[[package]]
name = "isodate"
version = "0.7.2"
@@ -3009,7 +3086,7 @@ version = "1.33"
description = "Apply JSON-Patches (RFC 6902)"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*"
groups = ["dev"]
groups = ["main", "dev"]
files = [
{file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"},
{file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"},
@@ -3040,7 +3117,7 @@ version = "3.0.0"
description = "Identify specific nodes in a JSON document (RFC 6901)"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
groups = ["main", "dev"]
files = [
{file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"},
{file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"},
@@ -3101,6 +3178,61 @@ files = [
[package.dependencies]
referencing = ">=0.31.0"
[[package]]
name = "keystoneauth1"
version = "5.11.1"
description = "Authentication Library for OpenStack Identity"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "python_version < \"3.10\""
files = [
{file = "keystoneauth1-5.11.1-py3-none-any.whl", hash = "sha256:4525adf03b6e591f4b9b8a72c3b14f6510a04816dd5a7aca6ebaa6dfc90b69e6"},
{file = "keystoneauth1-5.11.1.tar.gz", hash = "sha256:806f12c49b7f4b2cad3f5a460f7bdd81e4247c81b6042596a7fea8575f6591f3"},
]
[package.dependencies]
iso8601 = ">=2.0.0"
os-service-types = ">=1.2.0"
pbr = ">=2.0.0"
requests = ">=2.14.2"
stevedore = ">=1.20.0"
typing-extensions = ">=4.12"
[package.extras]
betamax = ["PyYAML (>=3.13)", "betamax (>=0.7.0)", "fixtures (>=3.0.0)"]
kerberos = ["requests-kerberos (>=0.8.0)"]
oauth1 = ["oauthlib (>=0.6.2)"]
saml2 = ["lxml (>=4.2.0)"]
test = ["PyYAML (>=3.12)", "bandit (>=1.7.6,<1.8.0)", "betamax (>=0.7.0)", "coverage (>=4.0)", "fixtures (>=3.0.0)", "flake8-docstrings (>=1.7.0,<1.8.0)", "flake8-import-order (>=0.18.2,<0.19.0)", "hacking (>=6.1.0,<6.2.0)", "lxml (>=4.2.0)", "oauthlib (>=0.6.2)", "oslo.config (>=5.2.0)", "oslo.utils (>=3.33.0)", "oslotest (>=3.2.0)", "requests-kerberos (>=0.8.0)", "requests-mock (>=1.2.0)", "stestr (>=1.0.0)", "testresources (>=2.0.0)", "testtools (>=2.2.0)"]
[[package]]
name = "keystoneauth1"
version = "5.13.0"
description = "Authentication Library for OpenStack Identity"
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "python_version >= \"3.10\""
files = [
{file = "keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41"},
{file = "keystoneauth1-5.13.0.tar.gz", hash = "sha256:57c9ca407207899b50d8ff1ca8abb4a4e7427461bfc1877eb8519c3989ce63ec"},
]
[package.dependencies]
iso8601 = ">=2.0.0"
os-service-types = ">=1.2.0"
pbr = ">=2.0.0"
requests = ">=2.14.2"
stevedore = ">=1.20.0"
typing-extensions = ">=4.12"
[package.extras]
betamax = ["PyYAML (>=3.13)", "betamax (>=0.7.0)", "fixtures (>=3.0.0)"]
kerberos = ["requests-kerberos (>=0.8.0)"]
oauth1 = ["oauthlib (>=0.6.2)"]
saml2 = ["lxml (>=4.2.0)"]
[[package]]
name = "kubernetes"
version = "32.0.1"
@@ -3885,6 +4017,46 @@ files = [
{file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
]
[[package]]
name = "netifaces"
version = "0.11.0"
description = "Portable network interface information."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"},
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"},
{file = "netifaces-0.11.0-cp27-cp27m-win32.whl", hash = "sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1"},
{file = "netifaces-0.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85"},
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea"},
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89"},
{file = "netifaces-0.11.0-cp34-cp34m-win32.whl", hash = "sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4"},
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4"},
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b"},
{file = "netifaces-0.11.0-cp35-cp35m-win32.whl", hash = "sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac"},
{file = "netifaces-0.11.0-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be"},
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1"},
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0"},
{file = "netifaces-0.11.0-cp36-cp36m-win32.whl", hash = "sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7"},
{file = "netifaces-0.11.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8"},
{file = "netifaces-0.11.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246"},
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5"},
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9"},
{file = "netifaces-0.11.0-cp37-cp37m-win32.whl", hash = "sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150"},
{file = "netifaces-0.11.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5"},
{file = "netifaces-0.11.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c"},
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3"},
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4"},
{file = "netifaces-0.11.0-cp38-cp38-win32.whl", hash = "sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048"},
{file = "netifaces-0.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05"},
{file = "netifaces-0.11.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d"},
{file = "netifaces-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff"},
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f"},
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1"},
{file = "netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32"},
]
[[package]]
name = "networkx"
version = "3.2.1"
@@ -4116,6 +4288,33 @@ jsonschema-path = ">=0.3.1,<0.4.0"
lazy-object-proxy = ">=1.7.1,<2.0.0"
openapi-schema-validator = ">=0.6.0,<0.7.0"
[[package]]
name = "openstacksdk"
version = "4.0.1"
description = "An SDK for building applications to work with OpenStack"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "openstacksdk-4.0.1-py3-none-any.whl", hash = "sha256:d63187a006fff7c1de1486c9e2e1073a787af402620c3c0ed0cf5291225998ac"},
{file = "openstacksdk-4.0.1.tar.gz", hash = "sha256:19faa1d5e6a78a2c1dc06a171e65e776ba82e9df23e1d08586225dc5ade9fc63"},
]
[package.dependencies]
cryptography = ">=2.7"
decorator = ">=4.4.1"
"dogpile.cache" = ">=0.6.5"
iso8601 = ">=0.1.11"
jmespath = ">=0.9.0"
jsonpatch = ">=1.16,<1.20 || >1.20"
keystoneauth1 = ">=3.18.0"
netifaces = ">=0.10.4"
os-service-types = ">=1.7.0"
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
platformdirs = ">=3"
PyYAML = ">=3.13"
requestsexceptions = ">=1.2.0"
[[package]]
name = "opentelemetry-api"
version = "1.35.0"
@@ -4165,6 +4364,39 @@ files = [
opentelemetry-api = "1.35.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "os-service-types"
version = "1.7.0"
description = "Python library for consuming OpenStack sevice-types-authority data"
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version < \"3.10\""
files = [
{file = "os-service-types-1.7.0.tar.gz", hash = "sha256:31800299a82239363995b91f1ebf9106ac7758542a1e4ef6dc737a5932878c6c"},
{file = "os_service_types-1.7.0-py2.py3-none-any.whl", hash = "sha256:0505c72205690910077fb72b88f2a1f07533c8d39f2fe75b29583481764965d6"},
]
[package.dependencies]
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
[[package]]
name = "os-service-types"
version = "1.8.2"
description = "Python library for consuming OpenStack sevice-types-authority data"
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "python_version >= \"3.10\""
files = [
{file = "os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e"},
{file = "os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575"},
]
[package.dependencies]
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
typing-extensions = ">=4.1.0"
[[package]]
name = "packaging"
version = "25.0"
@@ -4294,7 +4526,7 @@ version = "6.1.1"
description = "Python Build Reasonableness"
optional = false
python-versions = ">=2.6"
groups = ["dev"]
groups = ["main", "dev"]
files = [
{file = "pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76"},
{file = "pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b"},
@@ -4309,7 +4541,7 @@ version = "4.3.8"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
groups = ["main", "dev"]
files = [
{file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"},
{file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"},
@@ -4629,7 +4861,7 @@ description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
markers = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
@@ -5361,6 +5593,18 @@ requests = ">=2.0.0"
[package.extras]
rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
[[package]]
name = "requestsexceptions"
version = "1.4.0"
description = "Import exceptions from potentially bundled packages in requests."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3"},
{file = "requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065"},
]
[[package]]
name = "responses"
version = "0.25.7"
@@ -5870,7 +6114,7 @@ version = "5.4.1"
description = "Manage dynamic plugins for Python applications"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
groups = ["main", "dev"]
files = [
{file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"},
{file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"},
@@ -6614,4 +6858,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">3.9.1,<3.13"
content-hash = "adfc2da2c6e3e803f7a151b9697dbc3f461366a03e4504eb97498cbc72b2e48c"
content-hash = "f9ff21ae57caa3ddcd27f3753c29c1b3be2966709baed52e1bbc24e7bdc33f3c"
+22 -3
View File
@@ -2,7 +2,7 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.18.0] (Prowler UNRELEASED)
## [5.18.0] (Prowler v5.18.0)
### 🚀 Added
@@ -11,9 +11,14 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `codebuild_project_webhook_filters_use_anchored_patterns` check for AWS provider to detect CodeBreach vulnerability [(#9840)](https://github.com/prowler-cloud/prowler/pull/9840)
- `exchange_shared_mailbox_sign_in_disabled` check for M365 provider [(#9828)](https://github.com/prowler-cloud/prowler/pull/9828)
- CloudTrail Timeline abstraction for querying resource modification history [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
- Cloudflare `--account-id` filter argument [(#9894)](https://github.com/prowler-cloud/prowler/pull/9894)
- `rds_instance_extended_support` check for AWS provider [(#9865)](https://github.com/prowler-cloud/prowler/pull/9865)
- `OpenStack` provider support with Compute service including 1 security check [(#9811)](https://github.com/prowler-cloud/prowler/pull/9811)
- `OpenStack` documentation for the support in the CLI [(#9848)](https://github.com/prowler-cloud/prowler/pull/9848)
- Add HIPAA compliance framework for the Azure provider [(#9957)](https://github.com/prowler-cloud/prowler/pull/9957)
- Cloudflare provider credentials as constructor parameters (`api_token`, `api_key`, `api_email`) [(#9907)](https://github.com/prowler-cloud/prowler/pull/9907)
### Changed
### 🔄 Changed
- Update Azure App Service service metadata to new format [(#9613)](https://github.com/prowler-cloud/prowler/pull/9613)
- Update Azure Application Insights service metadata to new format [(#9614)](https://github.com/prowler-cloud/prowler/pull/9614)
@@ -23,6 +28,19 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Parallelize Azure Key Vault vaults and vaults contents retrieval to improve performance [(#9876)](https://github.com/prowler-cloud/prowler/pull/9876)
- Update Azure IAM service metadata to new format [(#9620)](https://github.com/prowler-cloud/prowler/pull/9620)
- Update Azure Policy service metadata to new format [(#9625)](https://github.com/prowler-cloud/prowler/pull/9625)
- Update Azure MySQL service metadata to new format [(#9623)](https://github.com/prowler-cloud/prowler/pull/9623)
- Update Azure Defender service metadata to new format [(#9618)](https://github.com/prowler-cloud/prowler/pull/9618)
- Make AWS cross-account checks configurable through `trusted_account_ids` config parameter [(#9692)](https://github.com/prowler-cloud/prowler/pull/9692)
- Update Azure PostgreSQL service metadata to new format [(#9626)](https://github.com/prowler-cloud/prowler/pull/9626)
- Update Azure SQL Server service metadata to new format [(#9627)](https://github.com/prowler-cloud/prowler/pull/9627)
- Update Azure Network service metadata to new format [(#9624)](https://github.com/prowler-cloud/prowler/pull/9624)
- Update Azure Storage service metadata to new format [(#9628)](https://github.com/prowler-cloud/prowler/pull/9628)
### 🐛 Fixed
- Duplicated findings in `entra_user_with_vm_access_has_mfa` check when user has multiple VM access roles [(#9914)](https://github.com/prowler-cloud/prowler/pull/9914)
- Jira integration failing with `INVALID_INPUT` error when sending findings with long resource UIDs exceeding 255-character summary limit [(#9926)](https://github.com/prowler-cloud/prowler/pull/9926)
- CSV/XLSX download failure in dashboard [(#9946)](https://github.com/prowler-cloud/prowler/pull/9946)
---
@@ -52,6 +70,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- CIS 5.0 compliance framework for the Azure provider [(#9777)](https://github.com/prowler-cloud/prowler/pull/9777)
- `Cloudflare` Bot protection, WAF, Privacy, Anti-Scraping and Zone configuration checks [(#9425)](https://github.com/prowler-cloud/prowler/pull/9425)
- `Cloudflare` `waf` and `dns record` checks [(#9426)](https://github.com/prowler-cloud/prowler/pull/9426)
- Container Image provider using Trivy for vulnerability and secret scanning
### Changed
+20 -7
View File
@@ -119,11 +119,14 @@ from prowler.providers.common.quick_inventory import run_provider_quick_inventor
from prowler.providers.gcp.models import GCPOutputOptions
from prowler.providers.github.models import GithubOutputOptions
from prowler.providers.iac.models import IACOutputOptions
from prowler.providers.image.exceptions.exceptions import ImageBaseException
from prowler.providers.image.models import ImageOutputOptions
from prowler.providers.kubernetes.models import KubernetesOutputOptions
from prowler.providers.llm.models import LLMOutputOptions
from prowler.providers.m365.models import M365OutputOptions
from prowler.providers.mongodbatlas.models import MongoDBAtlasOutputOptions
from prowler.providers.nhn.models import NHNOutputOptions
from prowler.providers.openstack.models import OpenStackOutputOptions
from prowler.providers.oraclecloud.models import OCIOutputOptions
@@ -205,8 +208,8 @@ def prowler():
# Load compliance frameworks
logger.debug("Loading compliance frameworks from .json files")
# Skip compliance frameworks for IAC and LLM providers
if provider != "iac" and provider != "llm":
# Skip compliance frameworks for IAC, LLM, and Image providers
if provider not in ("iac", "llm", "image"):
bulk_compliance_frameworks = Compliance.get_bulk(provider)
# Complete checks metadata with the compliance framework specification
bulk_checks_metadata = update_checks_metadata_with_compliance(
@@ -263,8 +266,8 @@ def prowler():
if not args.only_logs:
global_provider.print_credentials()
# Skip service and check loading for IAC and LLM providers
if provider != "iac" and provider != "llm":
# Skip service and check loading for IAC, LLM, and Image providers
if provider not in ("iac", "llm", "image"):
# Import custom checks from folder
if checks_folder:
custom_checks = parse_checks_from_folder(global_provider, checks_folder)
@@ -351,6 +354,8 @@ def prowler():
)
elif provider == "iac":
output_options = IACOutputOptions(args, bulk_checks_metadata)
elif provider == "image":
output_options = ImageOutputOptions(args, bulk_checks_metadata)
elif provider == "llm":
output_options = LLMOutputOptions(args, bulk_checks_metadata)
elif provider == "oraclecloud":
@@ -361,6 +366,10 @@ def prowler():
output_options = AlibabaCloudOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
elif provider == "openstack":
output_options = OpenStackOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
@@ -370,8 +379,8 @@ def prowler():
# Execute checks
findings = []
if provider == "iac" or provider == "llm":
# For IAC and LLM providers, run the scan directly
if provider in ("iac", "llm", "image"):
# For IAC, LLM, and Image providers, run the scan directly
if provider == "llm":
def streaming_callback(findings_batch):
@@ -381,7 +390,11 @@ def prowler():
findings = global_provider.run_scan(streaming_callback=streaming_callback)
else:
# Original behavior for IAC or non-verbose LLM
findings = global_provider.run()
try:
findings = global_provider.run()
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
# Note: IaC doesn't support granular progress tracking since Trivy runs as a black box
# and returns all findings at once. Progress tracking would just be 0% → 100%.
+820
View File
@@ -0,0 +1,820 @@
{
"Framework": "HIPAA",
"Name": "HIPAA compliance framework for Azure",
"Version": "",
"Provider": "Azure",
"Description": "The Health Insurance Portability and Accountability Act of 1996 (HIPAA) is legislation that helps US workers to retain health insurance coverage when they change or lose jobs. The legislation also seeks to encourage electronic health records to improve the efficiency and quality of the US healthcare system through improved information sharing. This framework maps HIPAA requirements to Microsoft Azure security best practices.",
"Requirements": [
{
"Id": "164_308_a_1_ii_a",
"Name": "164.308(a)(1)(ii)(A) Risk analysis",
"Description": "Conduct an accurate and thorough assessment of the potential risks and vulnerabilities to the confidentiality, integrity, and availability of electronic protected health information held by the covered entity or business associate.",
"Attributes": [
{
"ItemId": "164_308_a_1_ii_a",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"defender_ensure_defender_for_server_is_on",
"defender_ensure_defender_for_app_services_is_on",
"defender_ensure_defender_for_sql_servers_is_on",
"defender_ensure_defender_for_storage_is_on",
"defender_ensure_defender_for_keyvault_is_on",
"defender_ensure_defender_for_arm_is_on",
"defender_ensure_defender_for_dns_is_on",
"defender_ensure_defender_for_containers_is_on",
"defender_ensure_defender_for_cosmosdb_is_on",
"defender_ensure_mcas_is_enabled",
"policy_ensure_asc_enforcement_enabled"
]
},
{
"Id": "164_308_a_1_ii_b",
"Name": "164.308(a)(1)(ii)(B) Risk Management",
"Description": "Implement security measures sufficient to reduce risks and vulnerabilities to a reasonable and appropriate level to comply with 164.306(a): Ensure the confidentiality, integrity, and availability of all electronic protected health information the covered entity or business associate creates, receives, maintains, or transmits.",
"Attributes": [
{
"ItemId": "164_308_a_1_ii_b",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_ensure_encryption_with_customer_managed_keys",
"storage_infrastructure_encryption_is_enabled",
"storage_blob_public_access_level_is_disabled",
"storage_default_network_access_rule_is_denied",
"storage_ensure_private_endpoints_in_storage_accounts",
"sqlserver_tde_encryption_enabled",
"sqlserver_tde_encrypted_with_cmk",
"sqlserver_unrestricted_inbound_access",
"keyvault_key_rotation_enabled",
"keyvault_rbac_enabled",
"keyvault_private_endpoints",
"vm_ensure_attached_disks_encrypted_with_cmk",
"vm_ensure_unattached_disks_encrypted_with_cmk",
"network_ssh_internet_access_restricted",
"network_rdp_internet_access_restricted",
"network_http_internet_access_restricted",
"network_udp_internet_access_restricted",
"iam_subscription_roles_owner_custom_not_created",
"iam_custom_role_has_permissions_to_administer_resource_locks",
"cosmosdb_account_firewall_use_selected_networks",
"cosmosdb_account_use_private_endpoints",
"aks_clusters_public_access_disabled",
"aks_clusters_created_with_private_nodes"
]
},
{
"Id": "164_308_a_1_ii_d",
"Name": "164.308(a)(1)(ii)(D) Information system activity review",
"Description": "Implement procedures to regularly review records of information system activity, such as audit logs, access reports, and security incident tracking reports.",
"Attributes": [
{
"ItemId": "164_308_a_1_ii_d",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"monitor_diagnostic_setting_with_appropriate_categories",
"monitor_diagnostic_settings_exists",
"monitor_alert_create_policy_assignment",
"monitor_alert_delete_policy_assignment",
"monitor_alert_create_update_nsg",
"monitor_alert_delete_nsg",
"monitor_alert_create_update_security_solution",
"monitor_alert_delete_security_solution",
"sqlserver_auditing_enabled",
"sqlserver_auditing_retention_90_days",
"keyvault_logging_enabled",
"network_watcher_enabled",
"network_flow_log_captured_sent",
"network_flow_log_more_than_90_days",
"app_http_logs_enabled",
"appinsights_ensure_is_configured"
]
},
{
"Id": "164_308_a_3_i",
"Name": "164.308(a)(3)(i) Workforce security",
"Description": "Implement policies and procedures to ensure that all members of its workforce have appropriate access to electronic protected health information, as provided under paragraph (a)(4) of this section, and to prevent those workforce members who do not have access under paragraph (a)(4) of this section from obtaining access to electronic protected health information.",
"Attributes": [
{
"ItemId": "164_308_a_3_i",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_blob_public_access_level_is_disabled",
"storage_default_network_access_rule_is_denied",
"sqlserver_unrestricted_inbound_access",
"network_ssh_internet_access_restricted",
"network_rdp_internet_access_restricted",
"network_http_internet_access_restricted",
"iam_subscription_roles_owner_custom_not_created",
"iam_role_user_access_admin_restricted",
"containerregistry_not_publicly_accessible",
"app_function_not_publicly_accessible",
"aisearch_service_not_publicly_accessible",
"cosmosdb_account_firewall_use_selected_networks"
]
},
{
"Id": "164_308_a_3_ii_a",
"Name": "164.308(a)(3)(ii)(A) Authorization and/or supervision",
"Description": "Implement procedures for the authorization and/or supervision of workforce members who work with electronic protected health information or in locations where it might be accessed.",
"Attributes": [
{
"ItemId": "164_308_a_3_ii_a",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"monitor_diagnostic_setting_with_appropriate_categories",
"monitor_diagnostic_settings_exists",
"sqlserver_auditing_enabled",
"keyvault_logging_enabled",
"entra_privileged_user_has_mfa",
"entra_non_privileged_user_has_mfa",
"entra_security_defaults_enabled",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_user_with_vm_access_has_mfa",
"network_flow_log_captured_sent",
"app_http_logs_enabled"
]
},
{
"Id": "164_308_a_3_ii_b",
"Name": "164.308(a)(3)(ii)(B) Workforce clearance procedure",
"Description": "Implement procedures to determine that the access of a workforce member to electronic protected health information is appropriate.",
"Attributes": [
{
"ItemId": "164_308_a_3_ii_b",
"Section": "164.308 Administrative Safeguards",
"Service": "entra"
}
],
"Checks": [
"iam_subscription_roles_owner_custom_not_created",
"iam_role_user_access_admin_restricted",
"iam_custom_role_has_permissions_to_administer_resource_locks",
"entra_global_admin_in_less_than_five_users",
"entra_policy_default_users_cannot_create_security_groups",
"entra_policy_ensure_default_user_cannot_create_apps",
"entra_policy_guest_invite_only_for_admin_roles",
"entra_policy_guest_users_access_restrictions"
]
},
{
"Id": "164_308_a_3_ii_c",
"Name": "164.308(a)(3)(ii)(C) Termination procedures",
"Description": "Implement procedures for terminating access to electronic protected health information when the employment of, or other arrangement with, a workforce member ends or as required by determinations made as specified in paragraph (a)(3)(ii)(b).",
"Attributes": [
{
"ItemId": "164_308_a_3_ii_c",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_key_rotation_90_days",
"keyvault_key_rotation_enabled",
"keyvault_rbac_key_expiration_set",
"keyvault_rbac_secret_expiration_set",
"keyvault_key_expiration_set_in_non_rbac",
"keyvault_non_rbac_secret_expiration_set"
]
},
{
"Id": "164_308_a_4_i",
"Name": "164.308(a)(4)(i) Information access management",
"Description": "Implement policies and procedures for authorizing access to electronic protected health information that are consistent with the applicable requirements of subpart E of this part.",
"Attributes": [
{
"ItemId": "164_308_a_4_i",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"iam_subscription_roles_owner_custom_not_created",
"iam_role_user_access_admin_restricted",
"iam_custom_role_has_permissions_to_administer_resource_locks",
"keyvault_rbac_enabled",
"entra_global_admin_in_less_than_five_users",
"entra_policy_restricts_user_consent_for_apps",
"entra_policy_user_consent_for_verified_apps"
]
},
{
"Id": "164_308_a_4_ii_a",
"Name": "164.308(a)(4)(ii)(A) Isolating health care clearinghouse functions",
"Description": "If a health care clearinghouse is part of a larger organization, the clearinghouse must implement policies and procedures that protect the electronic protected health information of the clearinghouse from unauthorized access by the larger organization.",
"Attributes": [
{
"ItemId": "164_308_a_4_ii_a",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_ensure_encryption_with_customer_managed_keys",
"storage_infrastructure_encryption_is_enabled",
"storage_ensure_private_endpoints_in_storage_accounts",
"storage_default_network_access_rule_is_denied",
"sqlserver_tde_encryption_enabled",
"sqlserver_tde_encrypted_with_cmk",
"sqlserver_auditing_enabled",
"keyvault_key_rotation_enabled",
"keyvault_logging_enabled",
"keyvault_private_endpoints",
"vm_ensure_attached_disks_encrypted_with_cmk",
"vm_backup_enabled",
"cosmosdb_account_use_private_endpoints",
"databricks_workspace_cmk_encryption_enabled",
"databricks_workspace_vnet_injection_enabled"
]
},
{
"Id": "164_308_a_4_ii_b",
"Name": "164.308(a)(4)(ii)(B) Access authorization",
"Description": "Implement policies and procedures for granting access to electronic protected health information, as one illustrative example, through access to a workstation, transaction, program, process, or other mechanism.",
"Attributes": [
{
"ItemId": "164_308_a_4_ii_b",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"iam_subscription_roles_owner_custom_not_created",
"iam_role_user_access_admin_restricted",
"iam_custom_role_has_permissions_to_administer_resource_locks",
"keyvault_rbac_enabled",
"aks_cluster_rbac_enabled",
"cosmosdb_account_use_aad_and_rbac",
"sqlserver_azuread_administrator_enabled",
"entra_global_admin_in_less_than_five_users"
]
},
{
"Id": "164_308_a_4_ii_c",
"Name": "164.308(a)(4)(ii)(C) Access establishment and modification",
"Description": "Implement policies and procedures that, based upon the covered entity's or the business associate's access authorization policies, establish, document, review, and modify a user's right of access to a workstation, transaction, program, or process.",
"Attributes": [
{
"ItemId": "164_308_a_4_ii_c",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"iam_subscription_roles_owner_custom_not_created",
"iam_role_user_access_admin_restricted",
"storage_key_rotation_90_days",
"keyvault_key_rotation_enabled",
"keyvault_rbac_key_expiration_set",
"keyvault_rbac_secret_expiration_set",
"entra_global_admin_in_less_than_five_users",
"entra_policy_default_users_cannot_create_security_groups",
"entra_policy_ensure_default_user_cannot_create_apps"
]
},
{
"Id": "164_308_a_5_ii_b",
"Name": "164.308(a)(5)(ii)(B) Protection from malicious software",
"Description": "Procedures for guarding against, detecting, and reporting malicious software.",
"Attributes": [
{
"ItemId": "164_308_a_5_ii_b",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"defender_ensure_defender_for_server_is_on",
"defender_ensure_wdatp_is_enabled",
"defender_assessments_vm_endpoint_protection_installed",
"defender_ensure_system_updates_are_applied",
"defender_container_images_scan_enabled",
"defender_container_images_resolved_vulnerabilities"
]
},
{
"Id": "164_308_a_5_ii_c",
"Name": "164.308(a)(5)(ii)(C) Log-in monitoring",
"Description": "Procedures for monitoring log-in attempts and reporting discrepancies.",
"Attributes": [
{
"ItemId": "164_308_a_5_ii_c",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"defender_ensure_defender_for_server_is_on",
"defender_ensure_mcas_is_enabled",
"monitor_diagnostic_setting_with_appropriate_categories",
"entra_security_defaults_enabled",
"sqlserver_auditing_enabled",
"keyvault_logging_enabled"
]
},
{
"Id": "164_308_a_5_ii_d",
"Name": "164.308(a)(5)(ii)(D) Password management",
"Description": "Procedures for creating, changing, and safeguarding passwords.",
"Attributes": [
{
"ItemId": "164_308_a_5_ii_d",
"Section": "164.308 Administrative Safeguards",
"Service": "entra"
}
],
"Checks": [
"entra_security_defaults_enabled",
"entra_privileged_user_has_mfa",
"entra_non_privileged_user_has_mfa",
"storage_key_rotation_90_days",
"keyvault_key_rotation_enabled",
"keyvault_rbac_key_expiration_set",
"keyvault_rbac_secret_expiration_set"
]
},
{
"Id": "164_308_a_6_i",
"Name": "164.308(a)(6)(i) Security incident procedures",
"Description": "Implement policies and procedures to address security incidents.",
"Attributes": [
{
"ItemId": "164_308_a_6_i",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"monitor_alert_create_update_nsg",
"monitor_alert_delete_nsg",
"monitor_alert_create_update_security_solution",
"monitor_alert_delete_security_solution",
"monitor_alert_service_health_exists",
"defender_ensure_defender_for_server_is_on",
"defender_ensure_notify_alerts_severity_is_high",
"defender_ensure_notify_emails_to_owners",
"defender_additional_email_configured_with_a_security_contact",
"defender_attack_path_notifications_properly_configured"
]
},
{
"Id": "164_308_a_6_ii",
"Name": "164.308(a)(6)(ii) Response and reporting",
"Description": "Identify and respond to suspected or known security incidents; mitigate, to the extent practicable, harmful effects of security incidents that are known to the covered entity or business associate; and document security incidents and their outcomes.",
"Attributes": [
{
"ItemId": "164_308_a_6_ii",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"monitor_diagnostic_setting_with_appropriate_categories",
"monitor_diagnostic_settings_exists",
"monitor_alert_create_update_nsg",
"monitor_alert_delete_nsg",
"defender_ensure_defender_for_server_is_on",
"defender_ensure_notify_alerts_severity_is_high",
"defender_ensure_notify_emails_to_owners",
"defender_additional_email_configured_with_a_security_contact",
"sqlserver_auditing_enabled",
"keyvault_logging_enabled",
"network_flow_log_captured_sent",
"app_http_logs_enabled"
]
},
{
"Id": "164_308_a_7_i",
"Name": "164.308(a)(7)(i) Contingency plan",
"Description": "Establish (and implement as needed) policies and procedures for responding to an emergency or other occurrence (for example, fire, vandalism, system failure, and natural disaster) that damages systems that contain electronic protected health information.",
"Attributes": [
{
"ItemId": "164_308_a_7_i",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"vm_backup_enabled",
"vm_sufficient_daily_backup_retention_period",
"storage_blob_versioning_is_enabled",
"storage_ensure_soft_delete_is_enabled",
"storage_ensure_file_shares_soft_delete_is_enabled",
"storage_geo_redundant_enabled",
"keyvault_recoverable",
"sqlserver_auditing_retention_90_days"
]
},
{
"Id": "164_308_a_7_ii_a",
"Name": "164.308(a)(7)(ii)(A) Data backup plan",
"Description": "Establish and implement procedures to create and maintain retrievable exact copies of electronic protected health information.",
"Attributes": [
{
"ItemId": "164_308_a_7_ii_a",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"vm_backup_enabled",
"vm_sufficient_daily_backup_retention_period",
"storage_blob_versioning_is_enabled",
"storage_ensure_soft_delete_is_enabled",
"storage_ensure_file_shares_soft_delete_is_enabled",
"storage_geo_redundant_enabled",
"keyvault_recoverable",
"sqlserver_auditing_retention_90_days",
"postgresql_flexible_server_log_retention_days_greater_3"
]
},
{
"Id": "164_308_a_7_ii_b",
"Name": "164.308(a)(7)(ii)(B) Disaster recovery plan",
"Description": "Establish (and implement as needed) procedures to restore any loss of data.",
"Attributes": [
{
"ItemId": "164_308_a_7_ii_b",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"vm_backup_enabled",
"vm_sufficient_daily_backup_retention_period",
"storage_blob_versioning_is_enabled",
"storage_ensure_soft_delete_is_enabled",
"storage_geo_redundant_enabled",
"keyvault_recoverable"
]
},
{
"Id": "164_308_a_7_ii_c",
"Name": "164.308(a)(7)(ii)(C) Emergency mode operation plan",
"Description": "Establish (and implement as needed) procedures to enable continuation of critical business processes for protection of the security of electronic protected health information while operating in emergency mode.",
"Attributes": [
{
"ItemId": "164_308_a_7_ii_c",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"vm_backup_enabled",
"vm_sufficient_daily_backup_retention_period",
"storage_blob_versioning_is_enabled",
"storage_ensure_soft_delete_is_enabled",
"storage_geo_redundant_enabled",
"keyvault_recoverable"
]
},
{
"Id": "164_308_a_8",
"Name": "164.308(a)(8) Evaluation",
"Description": "Perform a periodic technical and nontechnical evaluation, based initially upon the standards implemented under this rule and subsequently, in response to environmental or operational changes affecting the security of electronic protected health information, that establishes the extent to which an entity's security policies and procedures meet the requirements of this subpart.",
"Attributes": [
{
"ItemId": "164_308_a_8",
"Section": "164.308 Administrative Safeguards",
"Service": "azure"
}
],
"Checks": [
"defender_ensure_defender_for_server_is_on",
"defender_ensure_mcas_is_enabled",
"sqlserver_vulnerability_assessment_enabled",
"sqlserver_va_periodic_recurring_scans_enabled",
"sqlserver_va_scan_reports_configured",
"sqlserver_va_emails_notifications_admins_enabled",
"policy_ensure_asc_enforcement_enabled"
]
},
{
"Id": "164_310_a_1",
"Name": "164.310(a)(1) Facility access controls",
"Description": "Implement policies and procedures to limit physical access to its electronic information systems and the facility or facilities in which they are housed, while ensuring that properly authorized access is allowed.",
"Attributes": [
{
"ItemId": "164_310_a_1",
"Section": "164.310 Physical Safeguards",
"Service": "azure"
}
],
"Checks": [
"network_ssh_internet_access_restricted",
"network_rdp_internet_access_restricted",
"network_http_internet_access_restricted",
"network_bastion_host_exists",
"vm_jit_access_enabled",
"aks_clusters_public_access_disabled",
"aks_clusters_created_with_private_nodes"
]
},
{
"Id": "164_310_d_1",
"Name": "164.310(d)(1) Device and media controls",
"Description": "Implement policies and procedures that govern the receipt and removal of hardware and electronic media that contain electronic protected health information into and out of a facility, and the movement of these items within the facility.",
"Attributes": [
{
"ItemId": "164_310_d_1",
"Section": "164.310 Physical Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_ensure_encryption_with_customer_managed_keys",
"storage_infrastructure_encryption_is_enabled",
"vm_ensure_attached_disks_encrypted_with_cmk",
"vm_ensure_unattached_disks_encrypted_with_cmk",
"vm_ensure_using_managed_disks",
"sqlserver_tde_encryption_enabled",
"databricks_workspace_cmk_encryption_enabled"
]
},
{
"Id": "164_312_a_1",
"Name": "164.312(a)(1) Access control",
"Description": "Implement technical policies and procedures for electronic information systems that maintain electronic protected health information to allow access only to those persons or software programs that have been granted access rights as specified in 164.308(a)(4).",
"Attributes": [
{
"ItemId": "164_312_a_1",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_blob_public_access_level_is_disabled",
"storage_default_network_access_rule_is_denied",
"storage_ensure_private_endpoints_in_storage_accounts",
"sqlserver_unrestricted_inbound_access",
"network_ssh_internet_access_restricted",
"network_rdp_internet_access_restricted",
"network_http_internet_access_restricted",
"iam_subscription_roles_owner_custom_not_created",
"iam_role_user_access_admin_restricted",
"entra_privileged_user_has_mfa",
"containerregistry_not_publicly_accessible",
"app_function_not_publicly_accessible",
"aisearch_service_not_publicly_accessible",
"cosmosdb_account_firewall_use_selected_networks",
"cosmosdb_account_use_private_endpoints",
"aks_clusters_public_access_disabled"
]
},
{
"Id": "164_312_a_2_i",
"Name": "164.312(a)(2)(i) Unique user identification",
"Description": "Assign a unique name and/or number for identifying and tracking user identity.",
"Attributes": [
{
"ItemId": "164_312_a_2_i",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"sqlserver_auditing_enabled",
"sqlserver_azuread_administrator_enabled",
"entra_security_defaults_enabled",
"storage_default_to_entra_authorization_enabled",
"cosmosdb_account_use_aad_and_rbac",
"postgresql_flexible_server_entra_id_authentication_enabled"
]
},
{
"Id": "164_312_a_2_ii",
"Name": "164.312(a)(2)(ii) Emergency access procedure",
"Description": "Establish (and implement as needed) procedures for obtaining necessary electronic protected health information during an emergency.",
"Attributes": [
{
"ItemId": "164_312_a_2_ii",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"vm_backup_enabled",
"vm_sufficient_daily_backup_retention_period",
"storage_blob_versioning_is_enabled",
"storage_ensure_soft_delete_is_enabled",
"storage_geo_redundant_enabled",
"keyvault_recoverable"
]
},
{
"Id": "164_312_a_2_iv",
"Name": "164.312(a)(2)(iv) Encryption and decryption",
"Description": "Implement a mechanism to encrypt and decrypt electronic protected health information.",
"Attributes": [
{
"ItemId": "164_312_a_2_iv",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_ensure_encryption_with_customer_managed_keys",
"storage_infrastructure_encryption_is_enabled",
"storage_secure_transfer_required_is_enabled",
"sqlserver_tde_encryption_enabled",
"sqlserver_tde_encrypted_with_cmk",
"keyvault_key_rotation_enabled",
"vm_ensure_attached_disks_encrypted_with_cmk",
"vm_ensure_unattached_disks_encrypted_with_cmk",
"databricks_workspace_cmk_encryption_enabled",
"monitor_storage_account_with_activity_logs_cmk_encrypted"
]
},
{
"Id": "164_312_b",
"Name": "164.312(b) Audit controls",
"Description": "Implement hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information.",
"Attributes": [
{
"ItemId": "164_312_b",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"monitor_diagnostic_setting_with_appropriate_categories",
"monitor_diagnostic_settings_exists",
"monitor_alert_create_policy_assignment",
"monitor_alert_delete_policy_assignment",
"monitor_alert_create_update_nsg",
"monitor_alert_delete_nsg",
"monitor_alert_create_update_sqlserver_fr",
"monitor_alert_delete_sqlserver_fr",
"sqlserver_auditing_enabled",
"sqlserver_auditing_retention_90_days",
"keyvault_logging_enabled",
"network_watcher_enabled",
"network_flow_log_captured_sent",
"network_flow_log_more_than_90_days",
"app_http_logs_enabled",
"appinsights_ensure_is_configured",
"postgresql_flexible_server_log_checkpoints_on",
"postgresql_flexible_server_log_connections_on",
"postgresql_flexible_server_log_disconnections_on",
"mysql_flexible_server_audit_log_enabled",
"mysql_flexible_server_audit_log_connection_activated"
]
},
{
"Id": "164_312_c_1",
"Name": "164.312(c)(1) Integrity",
"Description": "Implement policies and procedures to protect electronic protected health information from improper alteration or destruction.",
"Attributes": [
{
"ItemId": "164_312_c_1",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_ensure_encryption_with_customer_managed_keys",
"storage_blob_versioning_is_enabled",
"storage_secure_transfer_required_is_enabled",
"keyvault_key_rotation_enabled",
"keyvault_recoverable",
"sqlserver_tde_encryption_enabled",
"vm_ensure_attached_disks_encrypted_with_cmk"
]
},
{
"Id": "164_312_c_2",
"Name": "164.312(c)(2) Mechanism to authenticate electronic protected health information",
"Description": "Implement electronic mechanisms to corroborate that electronic protected health information has not been altered or destroyed in an unauthorized manner.",
"Attributes": [
{
"ItemId": "164_312_c_2",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_ensure_encryption_with_customer_managed_keys",
"storage_blob_versioning_is_enabled",
"storage_secure_transfer_required_is_enabled",
"keyvault_key_rotation_enabled",
"keyvault_logging_enabled",
"sqlserver_auditing_enabled",
"network_flow_log_captured_sent"
]
},
{
"Id": "164_312_d",
"Name": "164.312(d) Person or entity authentication",
"Description": "Implement procedures to verify that a person or entity seeking access to electronic protected health information is the one claimed.",
"Attributes": [
{
"ItemId": "164_312_d",
"Section": "164.312 Technical Safeguards",
"Service": "entra"
}
],
"Checks": [
"entra_security_defaults_enabled",
"entra_privileged_user_has_mfa",
"entra_non_privileged_user_has_mfa",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_user_with_vm_access_has_mfa",
"entra_trusted_named_locations_exists",
"sqlserver_azuread_administrator_enabled",
"postgresql_flexible_server_entra_id_authentication_enabled"
]
},
{
"Id": "164_312_e_1",
"Name": "164.312(e)(1) Transmission security",
"Description": "Implement technical security measures to guard against unauthorized access to electronic protected health information that is being transmitted over an electronic communications network.",
"Attributes": [
{
"ItemId": "164_312_e_1",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_secure_transfer_required_is_enabled",
"storage_ensure_minimum_tls_version_12",
"sqlserver_recommended_minimal_tls_version",
"app_minimum_tls_version_12",
"app_ensure_http_is_redirected_to_https",
"app_ensure_using_http20",
"app_function_ftps_deployment_disabled",
"app_ftp_deployment_disabled",
"network_ssh_internet_access_restricted",
"network_rdp_internet_access_restricted",
"mysql_flexible_server_minimum_tls_version_12",
"mysql_flexible_server_ssl_connection_enabled",
"postgresql_flexible_server_enforce_ssl_enabled"
]
},
{
"Id": "164_312_e_2_i",
"Name": "164.312(e)(2)(i) Integrity controls",
"Description": "Implement security measures to ensure that electronically transmitted electronic protected health information is not improperly modified without detection until disposed of.",
"Attributes": [
{
"ItemId": "164_312_e_2_i",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"monitor_diagnostic_setting_with_appropriate_categories",
"storage_secure_transfer_required_is_enabled",
"storage_ensure_minimum_tls_version_12",
"storage_blob_versioning_is_enabled",
"defender_ensure_defender_for_server_is_on",
"sqlserver_auditing_enabled",
"keyvault_logging_enabled",
"network_flow_log_captured_sent"
]
},
{
"Id": "164_312_e_2_ii",
"Name": "164.312(e)(2)(ii) Encryption",
"Description": "Implement a mechanism to encrypt electronic protected health information whenever deemed appropriate.",
"Attributes": [
{
"ItemId": "164_312_e_2_ii",
"Section": "164.312 Technical Safeguards",
"Service": "azure"
}
],
"Checks": [
"storage_ensure_encryption_with_customer_managed_keys",
"storage_infrastructure_encryption_is_enabled",
"storage_secure_transfer_required_is_enabled",
"storage_ensure_minimum_tls_version_12",
"sqlserver_tde_encryption_enabled",
"sqlserver_tde_encrypted_with_cmk",
"sqlserver_recommended_minimal_tls_version",
"keyvault_key_rotation_enabled",
"vm_ensure_attached_disks_encrypted_with_cmk",
"vm_ensure_unattached_disks_encrypted_with_cmk",
"app_minimum_tls_version_12",
"app_ensure_http_is_redirected_to_https",
"mysql_flexible_server_minimum_tls_version_12",
"mysql_flexible_server_ssl_connection_enabled",
"postgresql_flexible_server_enforce_ssl_enabled",
"databricks_workspace_cmk_encryption_enabled"
]
}
]
}
Whitespace-only changes.
+5 -1
View File
@@ -62,6 +62,7 @@ class Provider(str, Enum):
MONGODBATLAS = "mongodbatlas"
ORACLECLOUD = "oraclecloud"
ALIBABACLOUD = "alibabacloud"
OPENSTACK = "openstack"
# Compliance
@@ -74,7 +75,10 @@ def get_available_compliance_frameworks(provider=None):
if provider:
providers = [provider]
for provider in providers:
with os.scandir(f"{actual_directory}/../compliance/{provider}") as files:
compliance_dir = f"{actual_directory}/../compliance/{provider}"
if not os.path.isdir(compliance_dir):
continue
with os.scandir(compliance_dir) as files:
for file in files:
if file.is_file() and file.name.endswith(".json"):
available_compliance_frameworks.append(
+4 -1
View File
@@ -63,7 +63,10 @@ aws:
fargate_windows_latest_version: "1.0.0"
# AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries)
# AWS SSM Configuration (aws.ssm_documents_set_as_public)
# AWS SSM Configuration (ssm_documents_set_as_public)
# AWS S3 Configuration (s3_bucket_cross_account_access)
# AWS EventBridge Configuration (eventbridge_schema_registry_cross_account_access, eventbridge_bus_cross_account_access)
# AWS DynamoDB Configuration (dynamodb_table_cross_account_access)
# Single account environment: No action required. The AWS account number will be automatically added by the checks.
# Multi account environment: Any additional trusted account number should be added as a space separated list, e.g.
# trusted_account_ids : ["123456789012", "098765432109", "678901234567"]
@@ -0,0 +1,60 @@
### Project ID, Check and/or Region can be * to apply for all the cases.
### Resources and tags are lists that can have either Regex or Keywords.
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
### For each check you can except Project IDs, Regions, Resources and/or Tags.
########################### MUTELIST EXAMPLE ###########################
Mutelist:
Accounts:
"example-project-id": # Your OpenStack project ID
Checks:
"compute_instance_security_groups_attached":
Regions:
- "EU-WEST-PAR"
Resources:
- "prowler-test-fail" # Mute by instance name
- "example-instance-id" # Mute by instance ID
Description: "Mute prowler-test-fail instance in compute_instance_security_groups_attached check"
"compute_*":
Regions:
- "*"
Resources:
- "test-*" # Mute all resources starting with "test-"
Description: "Mute all test instances for all compute checks"
"*":
Regions:
- "*"
Resources:
- "dev-instance"
Tags:
- "environment=dev" # Mute resources with environment=dev tag
- "testing=true"
Description: "Mute all resources with specific tags"
"*": # Apply to all projects
Checks:
"compute_instance_security_groups_attached":
Regions:
- "EU-WEST-PAR"
Resources:
- "legacy-.*" # Regex: mute all instances starting with "legacy-"
Description: "Mute legacy instances in EU-WEST-PAR region"
"*":
Regions:
- "*"
Resources:
- "*"
Tags:
- "prowler-ignore=true" # Mute any resource with this tag across all checks
Description: "Global mute for resources tagged with prowler-ignore=true"
"identity_password_policy_enabled":
Regions:
- "*"
Resources:
- "*"
Exceptions:
Accounts:
- "production-project-id"
Regions:
- "US-EAST-1"
Description: "Mute identity_password_policy_enabled everywhere EXCEPT in production-project-id in US-EAST-1"
+4
View File
@@ -687,6 +687,10 @@ def execute(
is_finding_muted_args["account_id"] = (
global_provider.identity.account_id
)
elif global_provider.type == "openstack":
is_finding_muted_args["project_id"] = (
global_provider.identity.project_id
)
for finding in check_findings:
if global_provider.type == "cloudflare":
is_finding_muted_args["account_id"] = finding.account_id
+59
View File
@@ -163,6 +163,7 @@ class CheckMetadata(BaseModel):
check_id
and values.get("Provider") != "iac"
and values.get("Provider") != "llm"
and values.get("Provider") != "image"
):
service_from_check_id = check_id.split("_")[0]
if service_name != service_from_check_id:
@@ -183,6 +184,7 @@ class CheckMetadata(BaseModel):
check_id
and values.get("Provider") != "iac"
and values.get("Provider") != "llm"
and values.get("Provider") != "image"
):
if "-" in check_id:
raise ValueError(
@@ -865,6 +867,44 @@ class CheckReportIAC(Check_Report):
)
@dataclass
class CheckReportImage(Check_Report):
"""Contains the Container Image Check's finding information using Trivy."""
resource_name: str
image_digest: str
package_name: str
installed_version: str
fixed_version: str
def __init__(
self,
metadata: Optional[dict] = None,
finding: Optional[dict] = None,
image_name: str = "",
) -> None:
"""
Initialize the Container Image Check's finding information from a Trivy vulnerability/secret dict.
Args:
metadata (Dict): Check metadata.
finding (dict): A single vulnerability/secret result from Trivy's JSON output.
image_name (str): The container image name being scanned.
"""
if metadata is None:
metadata = {}
if finding is None:
finding = {}
super().__init__(metadata, finding)
self.resource = finding
self.resource_name = image_name
self.image_digest = finding.get("PkgID", "")
self.package_name = finding.get("PkgName", "")
self.installed_version = finding.get("InstalledVersion", "")
self.fixed_version = finding.get("FixedVersion", "")
@dataclass
class CheckReportLLM(Check_Report):
"""Contains the LLM Check's finding information."""
@@ -913,6 +953,25 @@ class CheckReportNHN(Check_Report):
self.location = getattr(resource, "location", "kr1")
@dataclass
class CheckReportOpenStack(Check_Report):
"""Contains the OpenStack Check's finding information."""
resource_name: str
resource_id: str
project_id: str
region: str
def __init__(self, metadata: Dict, resource: Any) -> None:
super().__init__(metadata, resource)
self.resource_name = getattr(
resource, "name", getattr(resource, "resource_name", "default")
)
self.resource_id = getattr(resource, "id", getattr(resource, "resource_id", ""))
self.project_id = getattr(resource, "project_id", "")
self.region = getattr(resource, "region", "global")
@dataclass
class CheckReportMongoDBAtlas(Check_Report):
"""Contains the MongoDB Atlas Check's finding information."""
+2 -2
View File
@@ -14,8 +14,8 @@ def recover_checks_from_provider(
Returns a list of tuples with the following format (check_name, check_path)
"""
try:
# Bypass check loading for IAC provider since it uses Trivy directly
if provider == "iac" or provider == "llm":
# Bypass check loading for IAC, LLM, and Image providers since they use external tools directly
if provider in ("iac", "llm", "image"):
return []
checks = []
+4 -2
View File
@@ -27,10 +27,10 @@ class ProwlerArgumentParser:
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,dashboard,iac} ...",
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ...",
epilog="""
Available Cloud Providers:
{aws,azure,gcp,kubernetes,m365,github,iac,llm,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare}
{aws,azure,gcp,kubernetes,m365,github,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -39,9 +39,11 @@ Available Cloud Providers:
github GitHub Provider
cloudflare Cloudflare Provider
oraclecloud Oracle Cloud Infrastructure Provider
openstack OpenStack Provider
alibabacloud Alibaba Cloud Provider
iac IaC Provider (Beta)
llm LLM Provider (Beta)
image Container Image Provider (PoC)
nhn NHN Provider (Unofficial)
mongodbatlas MongoDB Atlas Provider (Beta)
+31
View File
@@ -366,6 +366,37 @@ class Finding(BaseModel):
)
output_data["region"] = check_output.region
elif provider.type == "openstack":
output_data["auth_method"] = (
f"Username: {get_nested_attribute(provider, 'identity.username')}"
)
output_data["account_uid"] = get_nested_attribute(
provider, "identity.project_id"
)
output_data["account_name"] = get_nested_attribute(
provider, "identity.project_name"
)
output_data["resource_name"] = check_output.resource_name
output_data["resource_uid"] = check_output.resource_id
output_data["region"] = check_output.region
elif provider.type == "image":
output_data["auth_method"] = provider.auth_method
output_data["account_uid"] = "image"
output_data["account_name"] = "image"
output_data["resource_name"] = getattr(
check_output, "resource_name", ""
)
output_data["resource_uid"] = getattr(check_output, "resource_name", "")
output_data["region"] = getattr(check_output, "region", "container")
output_data["package_name"] = getattr(check_output, "package_name", "")
output_data["installed_version"] = getattr(
check_output, "installed_version", ""
)
output_data["fixed_version"] = getattr(
check_output, "fixed_version", ""
)
# check_output Unique ID
# TODO: move this to a function
# TODO: in Azure, GCP and K8s there are findings without resource_name
+72
View File
@@ -1160,6 +1160,78 @@ class HTML(Output):
)
return ""
@staticmethod
def get_openstack_assessment_summary(provider: Provider) -> str:
"""
get_openstack_assessment_summary gets the HTML assessment summary for the OpenStack provider
Args:
provider (Provider): the OpenStack provider object
Returns:
str: HTML assessment summary for the OpenStack provider
"""
try:
project_id = getattr(provider.identity, "project_id", "unknown")
project_name = getattr(provider.identity, "project_name", "")
region_name = getattr(provider.identity, "region_name", "unknown")
username = getattr(provider.identity, "username", "unknown")
user_id = getattr(provider.identity, "user_id", "")
project_name_item = (
f"""
<li class="list-group-item">
<b>Project Name:</b> {project_name}
</li>"""
if project_name
else ""
)
user_id_item = (
f"""
<li class="list-group-item">
<b>User ID:</b> {user_id}
</li>"""
if user_id
else ""
)
return f"""
<div class="col-md-2">
<div class="card">
<div class="card-header">
OpenStack Assessment Summary
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Project ID:</b> {project_id}
</li>
{project_name_item}
<li class="list-group-item">
<b>Region:</b> {region_name}
</li>
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
OpenStack Credentials
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Username:</b> {username}
</li>
{user_id_item}
</ul>
</div>
</div>"""
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
return ""
@staticmethod
def get_assessment_summary(provider: Provider) -> str:
"""
+3 -3
View File
@@ -1875,12 +1875,12 @@ class Jira:
summary_parts.append(finding.resource_uid)
summary = " - ".join(summary_parts[1:])
summary = f"{summary_parts[0]} {summary}"
summary = f"{summary_parts[0]} {summary}"[:255]
payload = {
"fields": {
"project": {"key": project_key},
"summary": f"[Prowler] {finding.metadata.Severity.value.upper()} - {finding.metadata.CheckID} - {finding.resource_uid}",
"summary": summary,
"description": adf_description,
"issuetype": {"name": issue_type},
"customfield_10148": {"value": "SDK"},
@@ -2081,7 +2081,7 @@ class Jira:
if resource_uid:
summary_parts.append(resource_uid)
summary = " - ".join(summary_parts[1:])
summary = f"{summary_parts[0]} {summary}"
summary = f"{summary_parts[0]} {summary}"[:255]
payload = {
"fields": {
+2
View File
@@ -32,6 +32,8 @@ def stdout_report(finding, color, verbose, status, fix):
details = finding.region
if finding.check_metadata.Provider == "alibabacloud":
details = finding.region
if finding.check_metadata.Provider == "openstack":
details = finding.region
if finding.check_metadata.Provider == "cloudflare":
details = finding.zone_name
+13
View File
@@ -86,6 +86,19 @@ def display_summary_table(
elif provider.type == "alibabacloud":
entity_type = "Account"
audited_entities = provider.identity.account_id
elif provider.type == "openstack":
entity_type = "Project"
audited_entities = (
provider.identity.project_name
if provider.identity.project_name
else provider.identity.project_id
)
elif provider.type == "image":
entity_type = "Image"
if len(provider.images) == 1:
audited_entities = provider.images[0]
else:
audited_entities = f"{len(provider.images)} images"
# Check if there are findings and that they are not all MANUAL
if findings and not all(finding.status == "MANUAL" for finding in findings):
+71 -8
View File
@@ -27,6 +27,7 @@ from prowler.lib.scan.exceptions.exceptions import (
from prowler.providers.common.models import Audit_Metadata, ProviderOutputOptions
from prowler.providers.common.provider import Provider
from prowler.providers.iac.iac_provider import IacProvider
from prowler.providers.image.image_provider import ImageProvider
class Scan:
@@ -92,10 +93,10 @@ class Scan:
except ValueError:
raise ScanInvalidStatusError(f"Invalid status provided: {s}.")
# Special setup for IaC provider - override inputs to work with traditional flow
if provider.type == "iac":
# IaC doesn't use traditional Prowler checks, so clear all input parameters
# to avoid validation errors and let it flow through the normal logic
# Special setup for IaC/Image providers - override inputs to work with traditional flow
if provider.type in ("iac", "image"):
# These providers don't use traditional Prowler checks, so clear all input
# parameters to avoid validation errors and let them flow through the normal logic
checks = None
services = None
excluded_checks = None
@@ -160,8 +161,8 @@ class Scan:
)
# Load checks to execute
if provider.type == "iac":
self._checks_to_execute = ["iac_scan"] # Dummy check name for IaC
if provider.type in ("iac", "image"):
self._checks_to_execute = [f"{provider.type}_scan"] # Dummy check name
else:
self._checks_to_execute = sorted(
load_checks_to_execute(
@@ -200,8 +201,8 @@ class Scan:
self._number_of_checks_to_execute = len(self._checks_to_execute)
# Set up service-based checks tracking
if provider.type == "iac":
service_checks_to_execute = {"iac": set(["iac_scan"])}
if provider.type in ("iac", "image"):
service_checks_to_execute = {provider.type: set([f"{provider.type}_scan"])}
else:
service_checks_to_execute = get_service_checks_to_execute(
self._checks_to_execute
@@ -346,6 +347,68 @@ class Scan:
self._duration = int((end_time - start_time).total_seconds())
return
# Special handling for Image provider
elif self._provider.type == "image":
if isinstance(self._provider, ImageProvider):
logger.info("Running Image scan with Trivy...")
total_images = len(self._provider.images)
self._number_of_checks_to_execute = max(total_images, 1)
for i, (image_name, image_reports) in enumerate(
self._provider.scan_per_image()
):
# Build resource UID from image name + SHA (all reports share the same SHA)
image_sha = image_reports[0].image_sha if image_reports else ""
resource_uid = (
f"{image_name}:{image_sha}" if image_sha else image_name
)
findings = []
for report in image_reports:
finding_uid = (
f"{report.check_metadata.CheckID}"
f"-{image_name}"
f"-{report.resource_id}"
)
status_enum = (
Status.FAIL if report.status == "FAIL" else Status.PASS
)
if report.muted:
status_enum = Status.MUTED
finding = Finding(
auth_method="Registry",
timestamp=datetime.datetime.now(timezone.utc),
account_uid=getattr(self._provider, "registry", None)
or "image",
account_name="Container Registry",
metadata=report.check_metadata,
uid=finding_uid,
status=status_enum,
status_extended=report.status_extended,
muted=report.muted,
resource_uid=resource_uid,
resource_metadata=report.resource,
resource_name=image_name,
resource_details=report.resource_details,
resource_tags={},
region=report.region,
compliance={},
raw=report.resource,
)
findings.append(finding)
if self._status:
findings = [f for f in findings if f.status in self._status]
self._number_of_checks_completed = i + 1
yield (self.progress, findings)
end_time = datetime.datetime.now()
self._duration = int((end_time - start_time).total_seconds())
return
for check_name in checks_to_execute:
try:
# Recover service from check name
@@ -38,5 +38,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding."
}
@@ -6,6 +6,9 @@ from prowler.providers.aws.services.iam.lib.policy import is_policy_public
class dynamodb_table_cross_account_access(Check):
def execute(self):
findings = []
trusted_account_ids = dynamodb_client.audit_config.get(
"trusted_account_ids", []
)
for table in dynamodb_client.tables.values():
if table.policy is None:
continue
@@ -20,6 +23,7 @@ class dynamodb_table_cross_account_access(Check):
table.policy,
dynamodb_client.audited_account,
is_cross_account_allowed=False,
trusted_account_ids=trusted_account_ids,
):
report.status = "FAIL"
report.status_extended = f"DynamoDB table {table.name} has a resource-based policy allowing cross account access."
@@ -40,5 +40,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding."
}
@@ -8,6 +8,9 @@ from prowler.providers.aws.services.iam.lib.policy import is_policy_public
class eventbridge_bus_cross_account_access(Check):
def execute(self):
findings = []
trusted_account_ids = eventbridge_client.audit_config.get(
"trusted_account_ids", []
)
for bus in eventbridge_client.buses.values():
if bus.policy is None:
continue
@@ -20,6 +23,7 @@ class eventbridge_bus_cross_account_access(Check):
bus.policy,
eventbridge_client.audited_account,
is_cross_account_allowed=False,
trusted_account_ids=trusted_account_ids,
):
report.status = "FAIL"
report.status_extended = (
Loaded 100 of 369 files, more files were not shown because too many files have changed in this diff. Show more