Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3d8430884 | |||
| e19ba3a5ed |
@@ -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_BATCH_SIZE=1000
|
||||
ATTACK_PATHS_FINDINGS_BATCH_SIZE=1000
|
||||
|
||||
# Celery-Prowler task settings
|
||||
TASK_RETRY_DELAY_SECONDS=0.1
|
||||
|
||||
@@ -57,11 +57,6 @@ 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/*"
|
||||
@@ -82,7 +77,6 @@ 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/**"
|
||||
@@ -93,7 +87,6 @@ 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:
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,402 +0,0 @@
|
||||
# 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/**
|
||||
|
||||
# E2E setup helpers (not runnable tests)
|
||||
- ui/tests/setups/**
|
||||
|
||||
# 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/**
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
@@ -414,30 +414,6 @@ 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'
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
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
|
||||
@@ -1,8 +1,4 @@
|
||||
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.
|
||||
name: UI - E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -10,23 +6,13 @@ on:
|
||||
- master
|
||||
- "v5.*"
|
||||
paths:
|
||||
- '.github/workflows/ui-e2e-tests-v2.yml'
|
||||
- '.github/test-impact.yml'
|
||||
- '.github/workflows/ui-e2e-tests.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')
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AUTH_SECRET: 'fallback-ci-secret-for-testing'
|
||||
@@ -65,95 +51,80 @@ jobs:
|
||||
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
|
||||
|
||||
# Modify the kubeconfig to use the kind cluster server to https://kind-control-plane:6443
|
||||
# from worker service into docker-compose.yml
|
||||
kubectl config set-cluster kind-kind --server=https://kind-control-plane:6443
|
||||
kubectl config view
|
||||
- name: Add network kind to docker compose
|
||||
run: |
|
||||
# Add the network kind to the docker compose to interconnect to kind cluster
|
||||
yq -i '.networks.kind.external = true' docker-compose.yml
|
||||
# Add network kind to worker service and default network too
|
||||
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
|
||||
- name: Add AWS credentials for testing AWS SDK Default Adding Provider
|
||||
run: |
|
||||
echo "Adding AWS credentials for testing AWS SDK Default Adding Provider..."
|
||||
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: |
|
||||
# Override docker-compose image tag to use latest instead of stable
|
||||
# This overrides any PROWLER_API_VERSION set in .env file
|
||||
export PROWLER_API_VERSION=latest
|
||||
echo "Using PROWLER_API_VERSION=${PROWLER_API_VERSION}"
|
||||
docker compose up -d api worker worker-beat
|
||||
|
||||
- name: Wait for API to be ready
|
||||
run: |
|
||||
echo "Waiting for prowler-api..."
|
||||
timeout=150
|
||||
timeout=150 # 5 minutes max
|
||||
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)"
|
||||
echo "Waiting for prowler-api... (${elapsed}s elapsed)"
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
echo "Timeout waiting for prowler-api"
|
||||
echo "Timeout waiting for prowler-api to start"
|
||||
exit 1
|
||||
|
||||
- name: Load database fixtures
|
||||
- name: Load database fixtures for E2E tests
|
||||
run: |
|
||||
docker compose exec -T api sh -c '
|
||||
echo "Loading all fixtures from api/fixtures/dev/..."
|
||||
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
|
||||
echo "All database fixtures loaded successfully!"
|
||||
'
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js environment
|
||||
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
|
||||
shell: bash
|
||||
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:
|
||||
@@ -165,15 +136,12 @@ jobs:
|
||||
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
|
||||
@@ -182,36 +150,13 @@ jobs:
|
||||
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)
|
||||
# Drop auth setup helpers (not runnable test suites)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
|
||||
if [[ -z "$TEST_PATHS" ]]; then
|
||||
echo "No runnable E2E test paths after filtering setups"
|
||||
exit 0
|
||||
fi
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | tr '\n' ' ')
|
||||
echo "Resolved test paths: $TEST_PATHS"
|
||||
pnpm exec playwright test $TEST_PATHS
|
||||
fi
|
||||
|
||||
run: pnpm run test:e2e
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: failure()
|
||||
@@ -219,27 +164,9 @@ jobs:
|
||||
name: playwright-report
|
||||
path: ui/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup services
|
||||
if: always()
|
||||
run: |
|
||||
echo "Shutting down services..."
|
||||
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
|
||||
echo "Cleanup completed"
|
||||
@@ -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/security) section in our documentation.
|
||||
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.
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.19.0] (Prowler v5.18.0)
|
||||
## [1.19.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 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)
|
||||
@@ -16,9 +15,11 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
### 🔄 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)
|
||||
|
||||
---
|
||||
|
||||
## [1.18.2] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ requires = ["poetry-core"]
|
||||
[project]
|
||||
authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
|
||||
dependencies = [
|
||||
"celery (>=5.4.0,<6.0.0)",
|
||||
"celery[pytest] (>=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@v5.18",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"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@0.126.1",
|
||||
"cartography @ git+https://github.com/prowler-cloud/cartography@master",
|
||||
"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.1"
|
||||
version = "1.19.0"
|
||||
|
||||
[project.scripts]
|
||||
celery = "src.backend.config.settings.celery"
|
||||
@@ -59,7 +59,6 @@ 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"
|
||||
@@ -72,5 +71,6 @@ pytest-randomly = "3.15.0"
|
||||
pytest-xdist = "3.6.1"
|
||||
ruff = "0.5.0"
|
||||
safety = "3.7.0"
|
||||
tqdm = "4.67.1"
|
||||
filelock = "3.20.3"
|
||||
vulture = "2.14"
|
||||
tqdm = "4.67.1"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from api.attack_paths.queries import (
|
||||
from api.attack_paths.query_definitions import (
|
||||
AttackPathsQueryDefinition,
|
||||
AttackPathsQueryParameterDefinition,
|
||||
get_queries_for_provider,
|
||||
get_query_by_id,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AttackPathsQueryDefinition",
|
||||
"AttackPathsQueryParameterDefinition",
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
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)
|
||||
@@ -86,8 +83,7 @@ def get_session(database: str | None = None) -> Iterator[RetryableSession]:
|
||||
yield session_wrapper
|
||||
|
||||
except neo4j.exceptions.Neo4jError as exc:
|
||||
message = exc.message if exc.message is not None else str(exc)
|
||||
raise GraphDatabaseQueryException(message=message, code=exc.code)
|
||||
raise GraphDatabaseQueryException(message=exc.message, code=exc.code)
|
||||
|
||||
finally:
|
||||
if session_wrapper is not None:
|
||||
@@ -109,41 +105,24 @@ def drop_database(database: str) -> None:
|
||||
session.run(query)
|
||||
|
||||
|
||||
def drop_subgraph(database: str, provider_id: str) -> int:
|
||||
"""
|
||||
Delete all nodes for a provider from the tenant database.
|
||||
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}
|
||||
|
||||
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,
|
||||
}
|
||||
with get_session(database) as session:
|
||||
result = session.run(query, parameters)
|
||||
|
||||
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
|
||||
try:
|
||||
return result.single()["deleted_nodes_count"]
|
||||
|
||||
except GraphDatabaseQueryException as exc:
|
||||
if exc.code == "Neo.ClientError.Database.DatabaseNotFound":
|
||||
return 0
|
||||
raise
|
||||
|
||||
return deleted_nodes
|
||||
except neo4j.exceptions.ResultConsumedError:
|
||||
return 0 # As there are no nodes to delete, the result is empty
|
||||
|
||||
|
||||
def clear_cache(database: str) -> None:
|
||||
@@ -158,11 +137,12 @@ def clear_cache(database: str) -> None:
|
||||
|
||||
|
||||
# Neo4j functions related to Prowler + Cartography
|
||||
DATABASE_NAME_TEMPLATE = "db-{attack_paths_scan_id}"
|
||||
|
||||
|
||||
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()}"
|
||||
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)
|
||||
|
||||
|
||||
# Exceptions
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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",
|
||||
]
|
||||
@@ -1,695 +0,0 @@
|
||||
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,
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
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)
|
||||
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
@@ -0,0 +1,690 @@
|
||||
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 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:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_ec2, 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,13 +1,12 @@
|
||||
import logging
|
||||
|
||||
from typing import Any, Iterable
|
||||
from typing import Any
|
||||
|
||||
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)
|
||||
|
||||
@@ -102,7 +101,7 @@ def _serialize_graph(graph):
|
||||
nodes.append(
|
||||
{
|
||||
"id": node.element_id,
|
||||
"labels": _filter_labels(node.labels),
|
||||
"labels": list(node.labels),
|
||||
"properties": _serialize_properties(node._properties),
|
||||
},
|
||||
)
|
||||
@@ -125,10 +124,6 @@ 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."""
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
||||
@@ -287,7 +287,6 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
IAC = "iac", _("IaC")
|
||||
ORACLECLOUD = "oraclecloud", _("Oracle Cloud Infrastructure")
|
||||
ALIBABACLOUD = "alibabacloud", _("Alibaba Cloud")
|
||||
CLOUDFLARE = "cloudflare", _("Cloudflare")
|
||||
|
||||
@staticmethod
|
||||
def validate_aws_uid(value):
|
||||
@@ -401,15 +400,6 @@ 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",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -20,7 +20,6 @@ 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
|
||||
@@ -119,7 +118,6 @@ class TestReturnProwlerProvider:
|
||||
(Provider.ProviderChoices.ORACLECLOUD.value, OraclecloudProvider),
|
||||
(Provider.ProviderChoices.IAC.value, IacProvider),
|
||||
(Provider.ProviderChoices.ALIBABACLOUD.value, AlibabacloudProvider),
|
||||
(Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider),
|
||||
],
|
||||
)
|
||||
def test_return_prowler_provider(self, provider_type, expected_provider):
|
||||
@@ -223,10 +221,6 @@ 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):
|
||||
|
||||
@@ -1174,11 +1174,6 @@ class TestProviderViewSet:
|
||||
"uid": "1234567890123456",
|
||||
"alias": "Alibaba Cloud Account",
|
||||
},
|
||||
{
|
||||
"provider": "cloudflare",
|
||||
"uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
|
||||
"alias": "Cloudflare Account",
|
||||
},
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1558,46 +1553,6 @@ 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",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1771,21 +1726,21 @@ class TestProviderViewSet:
|
||||
(
|
||||
"uid.icontains",
|
||||
"1",
|
||||
9,
|
||||
8,
|
||||
),
|
||||
("alias", "aws_testing_1", 1),
|
||||
("alias.icontains", "aws", 2),
|
||||
("inserted_at", TODAY, 10),
|
||||
("inserted_at", TODAY, 9),
|
||||
(
|
||||
"inserted_at.gte",
|
||||
"2024-01-01",
|
||||
10,
|
||||
9,
|
||||
),
|
||||
("inserted_at.lte", "2024-01-01", 0),
|
||||
(
|
||||
"updated_at.gte",
|
||||
"2024-01-01",
|
||||
10,
|
||||
9,
|
||||
),
|
||||
("updated_at.lte", "2024-01-01", 0),
|
||||
]
|
||||
@@ -2375,23 +2330,6 @@ 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",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_provider_secrets_create_valid(
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
@@ -92,7 +91,7 @@ def return_prowler_provider(
|
||||
provider (Provider): The provider object containing the provider type and associated secrets.
|
||||
|
||||
Returns:
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class.
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider type specified in `provider.provider` is not supported.
|
||||
@@ -146,12 +145,6 @@ def return_prowler_provider(
|
||||
)
|
||||
|
||||
prowler_provider = AlibabacloudProvider
|
||||
case Provider.ProviderChoices.CLOUDFLARE.value:
|
||||
from prowler.providers.cloudflare.cloudflare_provider import (
|
||||
CloudflareProvider,
|
||||
)
|
||||
|
||||
prowler_provider = CloudflareProvider
|
||||
case _:
|
||||
raise ValueError(f"Provider type {provider.provider} not supported")
|
||||
return prowler_provider
|
||||
@@ -203,11 +196,6 @@ 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],
|
||||
}
|
||||
|
||||
if mutelist_processor:
|
||||
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
|
||||
@@ -225,7 +213,6 @@ def initialize_prowler_provider(
|
||||
AlibabacloudProvider
|
||||
| AwsProvider
|
||||
| AzureProvider
|
||||
| CloudflareProvider
|
||||
| GcpProvider
|
||||
| GithubProvider
|
||||
| IacProvider
|
||||
@@ -241,7 +228,7 @@ def initialize_prowler_provider(
|
||||
mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration.
|
||||
|
||||
Returns:
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class
|
||||
initialized with the provider's secrets.
|
||||
"""
|
||||
prowler_provider = return_prowler_provider(provider)
|
||||
|
||||
@@ -346,33 +346,6 @@ 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"],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1503,18 +1503,6 @@ 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'."
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise serializers.ValidationError(
|
||||
{"provider": f"Provider type not supported {provider_type}"}
|
||||
@@ -1666,21 +1654,6 @@ 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()
|
||||
|
||||
@@ -392,7 +392,7 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
spectacular_settings.TITLE = "Prowler API"
|
||||
spectacular_settings.VERSION = "1.19.1"
|
||||
spectacular_settings.VERSION = "1.19.0"
|
||||
spectacular_settings.DESCRIPTION = (
|
||||
"Prowler API specification.\n\nThis file is auto-generated."
|
||||
)
|
||||
@@ -2287,7 +2287,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)),
|
||||
@@ -2307,7 +2307,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"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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
|
||||
@@ -12,11 +14,6 @@ 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,
|
||||
@@ -62,6 +59,11 @@ 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"
|
||||
@@ -531,12 +533,6 @@ 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,
|
||||
)
|
||||
|
||||
return (
|
||||
provider1,
|
||||
@@ -548,7 +544,6 @@ def providers_fixture(tenants_fixture):
|
||||
provider7,
|
||||
provider8,
|
||||
provider9,
|
||||
provider10,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def start_aws_ingestion(
|
||||
attack_paths_scan: ProwlerAPIAttackPathsScan,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Code based on Cartography, specifically on `cartography.intel.aws.__init__.py`.
|
||||
Code based on Cartography version 0.122.0, 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.
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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,6 +1,7 @@
|
||||
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
|
||||
@@ -9,7 +10,7 @@ from api.models import (
|
||||
Provider as ProwlerAPIProvider,
|
||||
StateChoices,
|
||||
)
|
||||
from tasks.jobs.attack_paths.config import is_provider_available
|
||||
from tasks.jobs.attack_paths.providers import is_provider_available
|
||||
|
||||
|
||||
def can_provider_run_attack_paths_scan(tenant_id: str, provider_id: int) -> bool:
|
||||
@@ -144,3 +145,24 @@ 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)
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,64 +0,0 @@
|
||||
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)
|
||||
@@ -0,0 +1,23 @@
|
||||
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")
|
||||
@@ -0,0 +1,290 @@
|
||||
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
|
||||
@@ -1,134 +0,0 @@
|
||||
# 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
|
||||
"""
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from cartography.config import Config as CartographyConfig
|
||||
from cartography.intel import analysis as cartography_analysis
|
||||
@@ -16,8 +17,7 @@ from api.models import (
|
||||
StateChoices,
|
||||
)
|
||||
from api.utils import initialize_prowler_provider
|
||||
from tasks.jobs.attack_paths import db_utils, findings, sync, utils
|
||||
from tasks.jobs.attack_paths.config import get_cartography_ingestion_function
|
||||
from tasks.jobs.attack_paths import aws, db_utils, prowler, utils
|
||||
|
||||
# Without this Celery goes crazy with Cartography logging
|
||||
logging.getLogger("cartography").setLevel(logging.ERROR)
|
||||
@@ -25,10 +25,18 @@ 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, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`,
|
||||
Code based on Cartography version 0.122.0, 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
|
||||
@@ -68,36 +76,22 @@ 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
|
||||
tmp_cartography_config = CartographyConfig(
|
||||
cartography_config = CartographyConfig(
|
||||
neo4j_uri=graph_database.get_uri(),
|
||||
neo4j_database=tmp_database_name,
|
||||
neo4j_database=graph_database.get_database_name(attack_paths_scan.id),
|
||||
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, tenant_cartography_config
|
||||
)
|
||||
db_utils.starting_attack_paths_scan(attack_paths_scan, task_id, cartography_config)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
|
||||
f"Creating Neo4j database {cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
|
||||
)
|
||||
|
||||
graph_database.create_database(tmp_cartography_config.neo4j_database)
|
||||
graph_database.create_database(cartography_config.neo4j_database)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 1)
|
||||
|
||||
logger.info(
|
||||
@@ -105,18 +99,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(
|
||||
tmp_cartography_config.neo4j_database
|
||||
) as tmp_neo4j_session:
|
||||
cartography_config.neo4j_database
|
||||
) as neo4j_session:
|
||||
# Indexes creation
|
||||
cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config)
|
||||
findings.create_findings_indexes(tmp_neo4j_session)
|
||||
cartography_create_indexes.run(neo4j_session, cartography_config)
|
||||
prowler.create_indexes(neo4j_session)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2)
|
||||
|
||||
# The real scan, where iterates over cloud services
|
||||
ingestion_exceptions = utils.call_within_event_loop(
|
||||
ingestion_exceptions = _call_within_event_loop(
|
||||
cartography_ingestion_function,
|
||||
tmp_neo4j_session,
|
||||
tmp_cartography_config,
|
||||
neo4j_session,
|
||||
cartography_config,
|
||||
prowler_api_provider,
|
||||
prowler_sdk_provider,
|
||||
attack_paths_scan,
|
||||
@@ -126,92 +120,43 @@ 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(tmp_neo4j_session, tmp_cartography_config)
|
||||
cartography_ontology.run(neo4j_session, 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(tmp_neo4j_session, tmp_cartography_config)
|
||||
cartography_analysis.run(neo4j_session, 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}"
|
||||
)
|
||||
findings.analysis(
|
||||
tmp_neo4j_session, prowler_api_provider, scan_id, tmp_cartography_config
|
||||
prowler.analysis(
|
||||
neo4j_session, prowler_api_provider, scan_id, cartography_config
|
||||
)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 97)
|
||||
|
||||
logger.info(
|
||||
f"Clearing Neo4j cache for database {tmp_cartography_config.neo4j_database}"
|
||||
f"Clearing Neo4j cache for database {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)
|
||||
graph_database.clear_cache(cartography_config.neo4j_database)
|
||||
|
||||
logger.info(
|
||||
f"Completed Cartography ({attack_paths_scan.id}) for "
|
||||
f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}"
|
||||
)
|
||||
|
||||
# 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
|
||||
# Handling databases changes
|
||||
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:
|
||||
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)
|
||||
graph_database.drop_database(old_attack_paths_scan.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
|
||||
)
|
||||
@@ -223,8 +168,30 @@ 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(tmp_cartography_config.neo4j_database)
|
||||
graph_database.drop_database(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)
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
"""
|
||||
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,40 +1,10 @@
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,7 @@ from api.models import (
|
||||
ScanSummary,
|
||||
Tenant,
|
||||
)
|
||||
from tasks.jobs.attack_paths.db_utils import get_provider_graph_database_names
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -32,13 +33,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 data related to the provider
|
||||
tenant_database_name = graph_database.get_database_name(tenant_id)
|
||||
# Delete the Attack Paths' graph databases related to the provider
|
||||
graph_database_names = get_provider_graph_database_names(tenant_id, pk)
|
||||
try:
|
||||
graph_database.drop_subgraph(tenant_database_name, str(pk))
|
||||
|
||||
for graph_database_name in graph_database_names:
|
||||
graph_database.drop_database(graph_database_name)
|
||||
except graph_database.GraphDatabaseQueryException as gdb_error:
|
||||
logger.error(f"Error deleting Provider graph data: {gdb_error}")
|
||||
logger.error(f"Error deleting Provider databases: {gdb_error}")
|
||||
raise
|
||||
|
||||
# Get all provider related data and delete them in batches
|
||||
@@ -89,13 +90,6 @@ 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
|
||||
|
||||
@@ -3,7 +3,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from tasks.jobs.attack_paths import findings as findings_module
|
||||
from tasks.jobs.attack_paths import prowler as prowler_module
|
||||
from tasks.jobs.attack_paths.scan import run as attack_paths_run
|
||||
|
||||
from api.models import (
|
||||
@@ -21,65 +21,7 @@ from prowler.lib.check.models import Severity
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAttackPathsRun:
|
||||
# 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,
|
||||
):
|
||||
def test_run_success_flow(self, tenants_fixture, providers_fixture, scans_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
@@ -103,22 +45,66 @@ 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",
|
||||
side_effect=["db-scan-id", "tenant-db"],
|
||||
return_value="db-scan-id",
|
||||
) 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")
|
||||
|
||||
@@ -126,40 +112,29 @@ 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 == "tenant-db"
|
||||
mock_get_db_name.assert_has_calls(
|
||||
[call(attack_paths_scan.id, temporary=True), call(provider.tenant_id)]
|
||||
)
|
||||
assert config.neo4j_database == "db-scan-id"
|
||||
|
||||
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_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_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
|
||||
@@ -206,8 +181,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.findings.create_findings_indexes"),
|
||||
patch("tasks.jobs.attack_paths.scan.findings.analysis"),
|
||||
patch("tasks.jobs.attack_paths.scan.prowler.create_indexes"),
|
||||
patch("tasks.jobs.attack_paths.scan.prowler.analysis"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
|
||||
return_value=attack_paths_scan,
|
||||
@@ -219,13 +194,12 @@ 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.utils.call_within_event_loop",
|
||||
"tasks.jobs.attack_paths.scan._call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
),
|
||||
patch(
|
||||
@@ -287,17 +261,15 @@ class TestAttackPathsRun:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAttackPathsFindingsHelpers:
|
||||
def test_create_findings_indexes_executes_all_statements(self):
|
||||
class TestAttackPathsProwlerHelpers:
|
||||
def test_create_indexes_executes_all_statements(self):
|
||||
mock_session = MagicMock()
|
||||
with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write:
|
||||
findings_module.create_findings_indexes(mock_session)
|
||||
with patch("tasks.jobs.attack_paths.prowler.run_write_query") as mock_run_write:
|
||||
prowler_module.create_indexes(mock_session)
|
||||
|
||||
from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS
|
||||
|
||||
assert mock_run_write.call_count == len(FINDINGS_INDEX_STATEMENTS)
|
||||
assert mock_run_write.call_count == len(prowler_module.INDEX_STATEMENTS)
|
||||
mock_run_write.assert_has_calls(
|
||||
[call(mock_session, stmt) for stmt in FINDINGS_INDEX_STATEMENTS]
|
||||
[call(mock_session, stmt) for stmt in prowler_module.INDEX_STATEMENTS]
|
||||
)
|
||||
|
||||
def test_load_findings_batches_requests(self, providers_fixture):
|
||||
@@ -305,35 +277,25 @@ class TestAttackPathsFindingsHelpers:
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
|
||||
# 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
|
||||
# Create a generator that yields two batches
|
||||
def findings_generator():
|
||||
yield [mock_finding_1]
|
||||
yield [mock_finding_2]
|
||||
yield [{"id": "1", "resource_uid": "r-1"}]
|
||||
yield [{"id": "2", "resource_uid": "r-2"}]
|
||||
|
||||
config = SimpleNamespace(update_tag=12345)
|
||||
mock_session = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_root_node_label",
|
||||
"tasks.jobs.attack_paths.prowler.get_root_node_label",
|
||||
return_value="AWSAccount",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_node_uid_field",
|
||||
"tasks.jobs.attack_paths.prowler.get_node_uid_field",
|
||||
return_value="arn",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
|
||||
return_value="AWSResource",
|
||||
),
|
||||
):
|
||||
findings_module.load_findings(
|
||||
prowler_module.load_findings(
|
||||
mock_session, findings_generator(), provider, config
|
||||
)
|
||||
|
||||
@@ -355,14 +317,14 @@ class TestAttackPathsFindingsHelpers:
|
||||
second_batch.single.return_value = {"deleted_findings_count": 0}
|
||||
mock_session.run.side_effect = [first_batch, second_batch]
|
||||
|
||||
findings_module.cleanup_findings(mock_session, provider, config)
|
||||
prowler_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_stream_findings_with_resources_returns_latest_scan_data(
|
||||
def test_get_provider_last_scan_findings_returns_latest_scan_data(
|
||||
self,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
@@ -440,18 +402,15 @@ class TestAttackPathsFindingsHelpers:
|
||||
|
||||
latest_scan.refresh_from_db()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.prowler.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
), patch(
|
||||
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
):
|
||||
# Generator yields batches, collect all findings from all batches
|
||||
findings_batches = findings_module.stream_findings_with_resources(
|
||||
findings_batches = prowler_module.get_provider_last_scan_findings(
|
||||
provider,
|
||||
str(latest_scan.id),
|
||||
)
|
||||
@@ -460,18 +419,18 @@ class TestAttackPathsFindingsHelpers:
|
||||
findings_data.extend(batch)
|
||||
|
||||
assert len(findings_data) == 1
|
||||
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)
|
||||
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)
|
||||
|
||||
def test_enrich_batch_with_resources_single_resource(
|
||||
def test_enrich_and_flatten_batch_single_resource(
|
||||
self,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""One finding + one resource = one output Finding instance"""
|
||||
"""One finding + one resource = one output dict"""
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
@@ -534,27 +493,25 @@ class TestAttackPathsFindingsHelpers:
|
||||
"muted_reason": finding.muted_reason,
|
||||
}
|
||||
|
||||
# _enrich_batch_with_resources queries ResourceFindingMapping directly
|
||||
# _enrich_and_flatten_batch queries ResourceFindingMapping directly
|
||||
# No RLS mock needed - test DB doesn't enforce RLS policies
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
|
||||
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
):
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
)
|
||||
result = prowler_module._enrich_and_flatten_batch([finding_dict])
|
||||
|
||||
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_batch_with_resources_multiple_resources(
|
||||
def test_enrich_and_flatten_batch_multiple_resources(
|
||||
self,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""One finding + three resources = three output Finding instances"""
|
||||
"""One finding + three resources = three output dicts"""
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
@@ -622,26 +579,24 @@ class TestAttackPathsFindingsHelpers:
|
||||
"muted_reason": finding.muted_reason,
|
||||
}
|
||||
|
||||
# _enrich_batch_with_resources queries ResourceFindingMapping directly
|
||||
# _enrich_and_flatten_batch queries ResourceFindingMapping directly
|
||||
# No RLS mock needed - test DB doesn't enforce RLS policies
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
|
||||
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
):
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
)
|
||||
result = prowler_module._enrich_and_flatten_batch([finding_dict])
|
||||
|
||||
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_batch_with_resources_no_resources_skips(
|
||||
def test_enrich_and_flatten_batch_no_resources_skips(
|
||||
self,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
@@ -697,14 +652,12 @@ class TestAttackPathsFindingsHelpers:
|
||||
# Mock logger to verify no warning is emitted
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
|
||||
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.findings.logger") as mock_logger,
|
||||
patch("tasks.jobs.attack_paths.prowler.logger") as mock_logger,
|
||||
):
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
)
|
||||
result = prowler_module._enrich_and_flatten_batch([finding_dict])
|
||||
|
||||
assert len(result) == 0
|
||||
mock_logger.warning.assert_not_called()
|
||||
@@ -717,11 +670,11 @@ class TestAttackPathsFindingsHelpers:
|
||||
scan_id = "some-scan-id"
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.attack_paths.findings.rls_transaction") as mock_rls,
|
||||
patch("tasks.jobs.attack_paths.findings.Finding") as mock_finding,
|
||||
patch("tasks.jobs.attack_paths.prowler.rls_transaction") as mock_rls,
|
||||
patch("tasks.jobs.attack_paths.prowler.Finding") as mock_finding,
|
||||
):
|
||||
# Create generator but don't iterate
|
||||
findings_module.stream_findings_with_resources(provider, scan_id)
|
||||
prowler_module.get_provider_last_scan_findings(provider, scan_id)
|
||||
|
||||
# Nothing should be called yet
|
||||
mock_rls.assert_not_called()
|
||||
@@ -742,18 +695,14 @@ class TestAttackPathsFindingsHelpers:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_root_node_label",
|
||||
"tasks.jobs.attack_paths.prowler.get_root_node_label",
|
||||
return_value="AWSAccount",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_node_uid_field",
|
||||
"tasks.jobs.attack_paths.prowler.get_node_uid_field",
|
||||
return_value="arn",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
|
||||
return_value="AWSResource",
|
||||
),
|
||||
):
|
||||
findings_module.load_findings(mock_session, empty_gen(), provider, config)
|
||||
prowler_module.load_findings(mock_session, empty_gen(), provider, config)
|
||||
|
||||
mock_session.run.assert_not_called()
|
||||
|
||||
@@ -11,15 +11,14 @@ 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.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,
|
||||
):
|
||||
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
|
||||
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
result = delete_provider(tenant_id, instance.id)
|
||||
@@ -28,32 +27,33 @@ class TestDeleteProvider:
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
Provider.objects.get(pk=instance.id)
|
||||
|
||||
mock_get_database_name.assert_called_once_with(tenant_id)
|
||||
mock_drop_subgraph.assert_called_once_with(
|
||||
"tenant-db",
|
||||
str(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]
|
||||
)
|
||||
|
||||
def test_delete_provider_does_not_exist(self, tenants_fixture):
|
||||
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,
|
||||
):
|
||||
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
|
||||
|
||||
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_database_name.assert_called_once_with(tenant_id)
|
||||
mock_drop_subgraph.assert_called_once_with(
|
||||
"tenant-db",
|
||||
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]
|
||||
)
|
||||
|
||||
|
||||
@@ -63,21 +63,21 @@ class TestDeleteTenant:
|
||||
"""
|
||||
Test successful deletion of a tenant and its related data.
|
||||
"""
|
||||
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,
|
||||
):
|
||||
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:
|
||||
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,42 +89,30 @@ class TestDeleteTenant:
|
||||
assert not Tenant.objects.filter(id=tenant.id).exists()
|
||||
assert not Provider.objects.filter(tenant_id=tenant.id).exists()
|
||||
|
||||
# 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)
|
||||
expected_calls = [
|
||||
call(provider.tenant_id, provider.id) for provider in providers
|
||||
]
|
||||
mock_get_database_name.assert_has_calls(
|
||||
expected_get_db_calls, any_order=True
|
||||
mock_get_provider_graph_database_names.assert_has_calls(
|
||||
expected_calls, any_order=True
|
||||
)
|
||||
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
|
||||
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
|
||||
]
|
||||
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")
|
||||
mock_drop_database.assert_has_calls(expected_drop_calls, any_order=True)
|
||||
assert mock_drop_database.call_count == len(expected_drop_calls)
|
||||
|
||||
def test_delete_tenant_with_no_providers(self, tenants_fixture):
|
||||
"""
|
||||
Test deletion of a tenant with no related providers.
|
||||
"""
|
||||
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,
|
||||
):
|
||||
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:
|
||||
tenant = tenants_fixture[1] # Assume this tenant has no providers
|
||||
providers = Provider.objects.filter(tenant_id=tenant.id)
|
||||
|
||||
@@ -138,7 +126,5 @@ class TestDeleteTenant:
|
||||
assert deletion_summary == {} # No providers, so empty summary
|
||||
assert not Tenant.objects.filter(id=tenant.id).exists()
|
||||
|
||||
# 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")
|
||||
mock_get_provider_graph_database_names.assert_not_called()
|
||||
mock_drop_database.assert_not_called()
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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"
|
||||
)
|
||||
@@ -1119,10 +1119,6 @@ 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:
|
||||
@@ -1179,25 +1175,22 @@ def filter_data(
|
||||
style={"height": "300px", "overflow-y": "auto"},
|
||||
)
|
||||
|
||||
# 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 = [
|
||||
color_bars = [
|
||||
color_mapping_severity[severity]
|
||||
for severity in df1["SEVERITY"].value_counts().index
|
||||
]
|
||||
|
||||
figure_bars = go.Figure(
|
||||
data=[
|
||||
go.Bar(
|
||||
x=df1["SEVERITY"].value_counts().index,
|
||||
x=df1["SEVERITY"]
|
||||
.value_counts()
|
||||
.index, # assign x as the dataframe column 'x'
|
||||
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"),
|
||||
@@ -1567,8 +1560,6 @@ def filter_data(
|
||||
severity_values,
|
||||
severity_filter_options,
|
||||
service_values,
|
||||
provider_values,
|
||||
provider_filter_options,
|
||||
service_filter_options,
|
||||
table_row_values,
|
||||
table_row_options,
|
||||
|
||||
@@ -155,8 +155,6 @@ 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
|
||||
|
||||
@@ -35,16 +35,6 @@ 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.
|
||||
|
||||
@@ -267,13 +267,6 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -115,8 +115,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.18.0"
|
||||
PROWLER_API_VERSION="5.18.0"
|
||||
PROWLER_UI_VERSION="5.17.0"
|
||||
PROWLER_API_VERSION="5.17.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -86,81 +86,3 @@ docker compose up -d
|
||||
<Note>
|
||||
We are evaluating adding these values to the default `docker-compose.yml` to avoid this issue in future releases.
|
||||
</Note>
|
||||
|
||||
### API Container Fails to Start with JWT Key Permission Error
|
||||
|
||||
See [GitHub Issue #8897](https://github.com/prowler-cloud/prowler/issues/8897) for more details.
|
||||
|
||||
When deploying Prowler via Docker Compose on a fresh installation, the API container may fail to start with permission errors related to JWT RSA key file generation. This issue is commonly observed on Linux systems (Ubuntu, Debian, cloud VMs) and Windows with Docker Desktop, but not typically on macOS.
|
||||
|
||||
**Error Message:**
|
||||
|
||||
Checking the API container logs reveals:
|
||||
|
||||
```bash
|
||||
PermissionError: [Errno 13] Permission denied: '/home/prowler/.config/prowler-api/jwt_private.pem'
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
Token generation failed due to invalid key configuration. Provide valid DJANGO_TOKEN_SIGNING_KEY and DJANGO_TOKEN_VERIFYING_KEY in the environment.
|
||||
```
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
This permission mismatch occurs due to UID (User ID) mapping between the host system and Docker containers:
|
||||
|
||||
* The API container runs as user `prowler` with UID/GID 1000
|
||||
* In environments like WSL2, the host user may have a different UID than the container user
|
||||
* Docker creates the mounted volume directory `./_data/api` on the host, often with the host user's UID or root ownership (UID 0)
|
||||
* When the application attempts to write JWT key files (`jwt_private.pem` and `jwt_public.pem`), the operation fails because the container's UID 1000 does not have write permissions to the host-owned directory
|
||||
|
||||
**Solutions:**
|
||||
|
||||
There are two approaches to resolve this issue:
|
||||
|
||||
**Option 1: Fix Volume Ownership (Resolve UID Mapping)**
|
||||
|
||||
Change the ownership of the volume directory to match the container user's UID (1000):
|
||||
|
||||
```bash
|
||||
# The container user 'prowler' has UID 1000
|
||||
# This command changes the directory ownership to UID 1000
|
||||
sudo chown -R 1000:1000 ./_data/api
|
||||
```
|
||||
|
||||
Then start Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This solution directly addresses the UID mapping mismatch by ensuring the volume directory is owned by the same UID that the container process uses.
|
||||
|
||||
**Option 2: Use Environment Variables (Skip File Storage)**
|
||||
|
||||
Generate JWT RSA keys manually and provide them via environment variables to bypass file-based key storage entirely:
|
||||
|
||||
```bash
|
||||
# Generate RSA keys
|
||||
openssl genrsa -out jwt_private.pem 4096
|
||||
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem
|
||||
|
||||
# Extract key content (removes headers/footers and newlines)
|
||||
PRIVATE_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' jwt_private.pem)
|
||||
PUBLIC_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' jwt_public.pem)
|
||||
```
|
||||
|
||||
Add the following to the `.env` file:
|
||||
|
||||
```env
|
||||
DJANGO_TOKEN_SIGNING_KEY=<content of jwt_private.pem>
|
||||
DJANGO_TOKEN_VERIFYING_KEY=<content of jwt_public.pem>
|
||||
```
|
||||
|
||||
When these environment variables are set, the API will use them directly instead of attempting to write key files to the mounted volume.
|
||||
|
||||
<Note>
|
||||
A fix addressing this permission issue is being evaluated in [PR #9953](https://github.com/prowler-cloud/prowler/pull/9953).
|
||||
</Note>
|
||||
|
||||
@@ -66,11 +66,6 @@ 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 |
|
||||
|
||||
@@ -207,10 +202,7 @@ aws:
|
||||
]
|
||||
|
||||
# AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries)
|
||||
# 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)
|
||||
# AWS SSM Configuration (aws.ssm_documents_set_as_public)
|
||||
# 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"]
|
||||
|
||||
|
After Width: | Height: | Size: 45 KiB |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'Cloudflare Authentication in Prowler'
|
||||
title: 'Cloudflare Authentication'
|
||||
---
|
||||
|
||||
Prowler for Cloudflare supports the following authentication methods:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'MongoDB Atlas Authentication in Prowler'
|
||||
title: 'MongoDB Atlas Authentication'
|
||||
---
|
||||
|
||||
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 in Prowler'
|
||||
title: 'Oracle Cloud Infrastructure (OCI) Authentication'
|
||||
---
|
||||
|
||||
This guide covers all authentication methods supported by Prowler for Oracle Cloud Infrastructure (OCI).
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
---
|
||||
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
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
||||

|
||||
|
||||
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"
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
```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)
|
||||
@@ -1,285 +0,0 @@
|
||||
---
|
||||
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
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before 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>
|
||||
## Organization
|
||||
## Membership
|
||||
|
||||
To get to User-Invitation Management we will focus on the Organization section.
|
||||
To get to User-Invitation Management we will focus on the Membership 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/organization.png" alt="Organization tab" width="400" />
|
||||
<img src="/images/prowler-app/rbac/membership.png" alt="Membership tab" width="700" />
|
||||
|
||||
### Users
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -2121,18 +2121,6 @@ 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"
|
||||
@@ -2255,60 +2243,6 @@ 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"
|
||||
@@ -2968,18 +2902,6 @@ 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"
|
||||
@@ -3086,7 +3008,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 = ["main", "dev"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"},
|
||||
{file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"},
|
||||
@@ -3117,7 +3039,7 @@ version = "3.0.0"
|
||||
description = "Identify specific nodes in a JSON document (RFC 6901)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "dev"]
|
||||
groups = ["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"},
|
||||
@@ -3178,61 +3100,6 @@ 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"
|
||||
@@ -4017,46 +3884,6 @@ 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"
|
||||
@@ -4288,33 +4115,6 @@ 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"
|
||||
@@ -4364,39 +4164,6 @@ 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"
|
||||
@@ -4526,7 +4293,7 @@ version = "6.1.1"
|
||||
description = "Python Build Reasonableness"
|
||||
optional = false
|
||||
python-versions = ">=2.6"
|
||||
groups = ["main", "dev"]
|
||||
groups = ["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"},
|
||||
@@ -4541,7 +4308,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 = ["main", "dev"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"},
|
||||
{file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"},
|
||||
@@ -4861,7 +4628,7 @@ description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
|
||||
markers = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""
|
||||
files = [
|
||||
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
|
||||
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
|
||||
@@ -5593,18 +5360,6 @@ 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"
|
||||
@@ -5874,6 +5629,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
|
||||
@@ -5882,6 +5638,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
|
||||
@@ -5890,6 +5647,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
|
||||
@@ -5898,6 +5656,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
|
||||
@@ -5906,6 +5665,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
|
||||
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
|
||||
@@ -6109,7 +5869,7 @@ version = "5.4.1"
|
||||
description = "Manage dynamic plugins for Python applications"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "dev"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"},
|
||||
{file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"},
|
||||
@@ -6853,4 +6613,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">3.9.1,<3.13"
|
||||
content-hash = "f9ff21ae57caa3ddcd27f3753c29c1b3be2966709baed52e1bbc24e7bdc33f3c"
|
||||
content-hash = "adfc2da2c6e3e803f7a151b9697dbc3f461366a03e4504eb97498cbc72b2e48c"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.18.0] (Prowler v5.18.0)
|
||||
## [5.18.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -12,13 +12,9 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- `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)
|
||||
@@ -29,18 +25,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -124,7 +124,6 @@ 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
|
||||
|
||||
|
||||
@@ -362,10 +361,6 @@ 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:
|
||||
|
||||
@@ -1,820 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.18.1"
|
||||
prowler_version = "5.18.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
@@ -62,7 +62,6 @@ class Provider(str, Enum):
|
||||
MONGODBATLAS = "mongodbatlas"
|
||||
ORACLECLOUD = "oraclecloud"
|
||||
ALIBABACLOUD = "alibabacloud"
|
||||
OPENSTACK = "openstack"
|
||||
|
||||
|
||||
# Compliance
|
||||
|
||||
@@ -63,10 +63,7 @@ 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 (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)
|
||||
# AWS SSM Configuration (aws.ssm_documents_set_as_public)
|
||||
# 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"]
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
### 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"
|
||||
@@ -687,10 +687,6 @@ 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
|
||||
|
||||
@@ -913,25 +913,6 @@ 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."""
|
||||
|
||||
@@ -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,openstack,dashboard,iac} ...",
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,dashboard,iac} ...",
|
||||
epilog="""
|
||||
Available Cloud Providers:
|
||||
{aws,azure,gcp,kubernetes,m365,github,iac,llm,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack}
|
||||
{aws,azure,gcp,kubernetes,m365,github,iac,llm,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare}
|
||||
aws AWS Provider
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
@@ -39,7 +39,6 @@ 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)
|
||||
|
||||
@@ -366,20 +366,6 @@ 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
|
||||
|
||||
# check_output Unique ID
|
||||
# TODO: move this to a function
|
||||
# TODO: in Azure, GCP and K8s there are findings without resource_name
|
||||
|
||||
@@ -1160,78 +1160,6 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -1875,12 +1875,12 @@ class Jira:
|
||||
summary_parts.append(finding.resource_uid)
|
||||
|
||||
summary = " - ".join(summary_parts[1:])
|
||||
summary = f"{summary_parts[0]} {summary}"[:255]
|
||||
summary = f"{summary_parts[0]} {summary}"
|
||||
|
||||
payload = {
|
||||
"fields": {
|
||||
"project": {"key": project_key},
|
||||
"summary": summary,
|
||||
"summary": f"[Prowler] {finding.metadata.Severity.value.upper()} - {finding.metadata.CheckID} - {finding.resource_uid}",
|
||||
"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}"[:255]
|
||||
summary = f"{summary_parts[0]} {summary}"
|
||||
|
||||
payload = {
|
||||
"fields": {
|
||||
|
||||
@@ -32,8 +32,6 @@ 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
|
||||
|
||||
|
||||
@@ -86,13 +86,6 @@ 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
|
||||
)
|
||||
|
||||
# Check if there are findings and that they are not all MANUAL
|
||||
if findings and not all(finding.status == "MANUAL" for finding in findings):
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding."
|
||||
"Notes": ""
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ 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
|
||||
@@ -23,7 +20,6 @@ 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": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding."
|
||||
"Notes": ""
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ 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
|
||||
@@ -23,7 +20,6 @@ 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 = (
|
||||
|
||||
@@ -39,5 +39,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding."
|
||||
"Notes": ""
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ from prowler.providers.aws.services.iam.lib.policy import is_policy_public
|
||||
class eventbridge_schema_registry_cross_account_access(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
trusted_account_ids = schema_client.audit_config.get("trusted_account_ids", [])
|
||||
for registry in schema_client.registries.values():
|
||||
if registry.policy is None:
|
||||
continue
|
||||
@@ -17,7 +16,6 @@ class eventbridge_schema_registry_cross_account_access(Check):
|
||||
registry.policy,
|
||||
schema_client.audited_account,
|
||||
is_cross_account_allowed=False,
|
||||
trusted_account_ids=trusted_account_ids,
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"EventBridge schema registry {registry.name} allows cross-account access."
|
||||
|
||||
@@ -387,7 +387,6 @@ def is_policy_public(
|
||||
is_cross_account_allowed=True,
|
||||
not_allowed_actions: list = [],
|
||||
check_cross_service_confused_deputy=False,
|
||||
trusted_account_ids: list = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the policy allows public access to the resource.
|
||||
@@ -398,19 +397,10 @@ def is_policy_public(
|
||||
is_cross_account_allowed (bool): If the policy can allow cross-account access, default: True (https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html#cross-service-confused-deputy-prevention)
|
||||
not_allowed_actions (list): List of actions that are not allowed, default: []. If not_allowed_actions is empty, the function will not consider the actions in the policy.
|
||||
check_cross_service_confused_deputy (bool): If the policy is checked for cross-service confused deputy, default: False
|
||||
trusted_account_ids (list): A list of trusted accound ids to reduce false positives on cross-account checks
|
||||
Returns:
|
||||
bool: True if the policy allows public access, False otherwise
|
||||
"""
|
||||
is_public = False
|
||||
|
||||
if trusted_account_ids is None:
|
||||
trusted_account_ids = []
|
||||
|
||||
trusted_accounts = set(trusted_account_ids)
|
||||
if source_account:
|
||||
trusted_accounts.add(source_account)
|
||||
|
||||
if policy:
|
||||
for statement in policy.get("Statement", []):
|
||||
# Only check allow statements
|
||||
@@ -424,19 +414,13 @@ def is_policy_public(
|
||||
isinstance(principal.get("AWS"), str)
|
||||
and source_account
|
||||
and not is_cross_account_allowed
|
||||
and not any(
|
||||
trusted_account in principal.get("AWS", "")
|
||||
for trusted_account in trusted_accounts
|
||||
)
|
||||
and source_account not in principal.get("AWS", "")
|
||||
) or (
|
||||
isinstance(principal.get("AWS"), list)
|
||||
and source_account
|
||||
and not is_cross_account_allowed
|
||||
and not all(
|
||||
any(
|
||||
trusted_account in principal_aws
|
||||
for trusted_account in trusted_accounts
|
||||
)
|
||||
and not any(
|
||||
source_account in principal_aws
|
||||
for principal_aws in principal["AWS"]
|
||||
)
|
||||
):
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "rds_instance_extended_support",
|
||||
"CheckTitle": "RDS instance is not enrolled in RDS Extended Support",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/Patch Management",
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "rds",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsRdsDbInstance",
|
||||
"ResourceGroup": "database",
|
||||
"Description": "**RDS DB instances** are evaluated for enrollment in Amazon RDS Extended Support. The check fails if `EngineLifecycleSupportis` set to `open-source-rds-extended-support`, indicating the instance will incur additional charges after standard support ends.",
|
||||
"Risk": "DB instances enrolled in RDS Extended Support can incur additional charges after the end of standard support for the running database major version. Remaining on older major versions can also delay necessary upgrades, increasing operational and security risk.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/extended-support-viewing.html",
|
||||
"https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/extended-support-charges.html",
|
||||
"https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/extended-support-creating-db-instance.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws rds modify-db-instance --db-instance-identifier <DB_INSTANCE_IDENTIFIER> --engine-version <TARGET_ENGINE_VERSION> --allow-major-version-upgrade --apply-immediately\n# For new DB instances created via automation, prevent enrollment by setting the lifecycle option:\naws rds create-db-instance ... --engine-lifecycle-support open-source-rds-extended-support-disabled",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: upgrade RDS engine version for an existing instance\nResources:\n <example_resource_name>:\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: <example_resource_id>\n Engine: <engine>\n DBInstanceClass: db.t3.micro\n EngineVersion: <SUPPORTED_ENGINE_VERSION> # CRITICAL: move to a supported engine version\n AllowMajorVersionUpgrade: true # CRITICAL: required if upgrading major version\n ApplyImmediately: true # CRITICAL: apply change now to pass the check\n```",
|
||||
"Other": "If your automation (CloudFormation/Terraform/SDK) creates or restores DB instances, set EngineLifecycleSupport/LifeCycleSupport to open-source-rds-extended-support-disabled where supported, and ensure your upgrade process keeps engines within standard support.",
|
||||
"Terraform": "```hcl\n# Upgrade RDS engine version\nresource \"aws_db_instance\" \"<example_resource_name>\" {\n identifier = \"<example_resource_id>\"\n engine = \"<engine>\"\n instance_class = \"db.t3.micro\"\n allocated_storage = 20\n\n engine_version = \"<SUPPORTED_ENGINE_VERSION>\" # CRITICAL: use a supported version\n allow_major_version_upgrade = true # CRITICAL: needed for major upgrades\n apply_immediately = true # CRITICAL: apply now to pass the check\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Upgrade enrolled DB instances to an engine version covered under standard support to stop Extended Support charges. For new DB instances and restores created via automation, explicitly set the engine lifecycle support option to avoid unintended enrollment in RDS Extended Support when that is your policy.",
|
||||
"Url": "https://hub.prowler.com/check/rds_instance_extended_support"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"vulnerabilities"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
Prowler check: rds_instance_extended_support
|
||||
|
||||
This check fails when an RDS DB instance is enrolled in Amazon RDS Extended Support.
|
||||
Enrollment is exposed via the "EngineLifecycleSupport" attribute returned by DescribeDBInstances.
|
||||
"""
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.rds.rds_client import rds_client
|
||||
|
||||
|
||||
class rds_instance_extended_support(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
|
||||
for db_instance in rds_client.db_instances.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=db_instance)
|
||||
|
||||
# EngineLifecycleSupport can be absent when Extended Support is not applicable.
|
||||
lifecycle_support = getattr(db_instance, "engine_lifecycle_support", None)
|
||||
|
||||
if lifecycle_support == "open-source-rds-extended-support":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"RDS instance {db_instance.id} ({db_instance.engine} {db_instance.engine_version}) "
|
||||
f"is enrolled in RDS Extended Support (EngineLifecycleSupport={lifecycle_support})."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"RDS instance {db_instance.id} ({db_instance.engine} {db_instance.engine_version}) "
|
||||
"is not enrolled in RDS Extended Support."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -59,9 +59,6 @@ class RDS(AWSService):
|
||||
endpoint=instance.get("Endpoint", {}),
|
||||
engine=instance["Engine"],
|
||||
engine_version=instance["EngineVersion"],
|
||||
engine_lifecycle_support=instance.get(
|
||||
"EngineLifecycleSupport"
|
||||
),
|
||||
status=instance["DBInstanceStatus"],
|
||||
public=instance.get("PubliclyAccessible", False),
|
||||
encrypted=instance["StorageEncrypted"],
|
||||
@@ -534,7 +531,6 @@ class DBInstance(BaseModel):
|
||||
endpoint: dict
|
||||
engine: str
|
||||
engine_version: str
|
||||
engine_lifecycle_support: Optional[str] = None
|
||||
status: str
|
||||
public: bool
|
||||
encrypted: bool
|
||||
|
||||
@@ -39,5 +39,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding."
|
||||
"Notes": ""
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ from prowler.providers.aws.services.s3.s3_client import s3_client
|
||||
class s3_bucket_cross_account_access(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
trusted_account_ids = s3_client.audit_config.get("trusted_account_ids", [])
|
||||
for bucket in s3_client.buckets.values():
|
||||
if bucket.policy is None:
|
||||
continue
|
||||
@@ -20,10 +19,7 @@ class s3_bucket_cross_account_access(Check):
|
||||
f"S3 Bucket {bucket.name} does not have a bucket policy."
|
||||
)
|
||||
elif is_policy_public(
|
||||
bucket.policy,
|
||||
s3_client.audited_account,
|
||||
is_cross_account_allowed=False,
|
||||
trusted_account_ids=trusted_account_ids,
|
||||
bucket.policy, s3_client.audited_account, is_cross_account_allowed=False
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"S3 Bucket {bucket.name} has a bucket policy allowing cross account access."
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "defender_additional_email_configured_with_a_security_contact",
|
||||
"CheckTitle": "Security contact has additional email addresses configured",
|
||||
"CheckTitle": "Ensure 'Additional email addresses' is Configured with a Security Contact Email",
|
||||
"CheckType": [],
|
||||
"ServiceName": "defender",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "microsoft.resources/subscriptions",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AzureEmailNotifications",
|
||||
"ResourceGroup": "monitoring",
|
||||
"Description": "**Microsoft Defender for Cloud** security contact settings include **additional email recipients** defined in the `emails` field to receive alert notifications.",
|
||||
"Risk": "Relying only on subscription owners for alerts creates a **single point of failure**. Missed or delayed notifications extend attacker dwell time, enabling data exfiltration (**confidentiality**), unauthorized changes (**integrity**), and service disruption (**availability**). Absence or turnover can silently suppress alerts.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/security-contact-email.html",
|
||||
"https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications",
|
||||
"https://learn.microsoft.com/en-us/azure/azure-sql/managed-instance/threat-detection-configure?view=azuresql"
|
||||
],
|
||||
"Description": "Microsoft Defender for Cloud emails the subscription owners whenever a high-severity alert is triggered for their subscription. You should provide a security contact email address as an additional email address.",
|
||||
"Risk": "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method put --url https://management.azure.com/subscriptions/<SUBSCRIPTION_ID>/providers/Microsoft.Security/securityContacts/default?api-version=2020-01-01-preview --body '{ \"properties\": { \"emails\": \"<EMAIL>\" } }'",
|
||||
"NativeIaC": "```bicep\n// Configure a security contact at subscription scope\ntargetScope = 'subscription'\n\nresource <example_resource_name> 'Microsoft.Security/securityContacts@2020-01-01-preview' = {\n name: 'default'\n properties: {\n emails: '<EMAIL>' // Critical: set at least one email to pass the check\n }\n}\n```",
|
||||
"Other": "1. Sign in to the Azure portal\n2. Go to Microsoft Defender for Cloud > Environment settings\n3. Select the target subscription\n4. Click Email notifications\n5. In Email addresses, enter at least one email (comma-separated for multiple)\n6. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azurerm_security_center_contact\" \"<example_resource_name>\" {\n email = \"<EMAIL>\" # Critical: ensures at least one security contact email is configured\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/security-contact-email.html",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-security-contact-emails-is-set#terraform"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use a monitored, team-managed distribution list as the **security contact** in `emails`. Include SOC/on-call for 24/7 coverage and enable role-based notifications for redundancy. Tune severities to reduce noise while capturing high-risk events, and integrate alerts with ticketing/SIEM for **defense in depth** and rapid response.",
|
||||
"Url": "https://hub.prowler.com/check/defender_additional_email_configured_with_a_security_contact"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Defender for Cloud 3. Click on Environment Settings 4. Click on the appropriate Management Group, Subscription, or Workspace 5. Click on Email notifications 6. Enter a valid security contact email address (or multiple addresses separated by commas) in the Additional email addresses field 7. Click Save",
|
||||
"Url": "https://learn.microsoft.com/en-us/rest/api/defenderforcloud/security-contacts/list?view=rest-defenderforcloud-2020-01-01-preview&tabs=HTTP"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"forensics-ready"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "defender_assessments_vm_endpoint_protection_installed",
|
||||
"CheckTitle": "All virtual machines in the subscription have endpoint protection installed",
|
||||
"CheckTitle": "Ensure that Endpoint Protection for all Virtual Machines is installed",
|
||||
"CheckType": [],
|
||||
"ServiceName": "defender",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.security/assessments/governanceassignments",
|
||||
"ResourceType": "Microsoft.Security/assessments",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure virtual machines** are assessed for the presence of an **endpoint protection (antimalware)** solution and its reported health across the subscription",
|
||||
"Risk": "Absent or unhealthy **endpoint protection** lets malware execute on VMs, risking:\n- Data exfiltration (confidentiality)\n- Tampering and credential theft (integrity)\n- Ransomware, cryptomining, and outages (availability)\n\nIt also enables persistence and lateral movement to other cloud resources.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/install-endpoint-protection.html#",
|
||||
"https://learn.microsoft.com/en-us/azure/security/fundamentals/antimalware"
|
||||
],
|
||||
"Description": "Install endpoint protection for all virtual machines.",
|
||||
"Risk": "Installing endpoint protection systems (like anti-malware for Azure) provides for real-time protection capability that helps identify and remove viruses, spyware, and other malicious software. These also offer configurable alerts when known-malicious or unwanted software attempts to install itself or run on Azure systems.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/security/fundamentals/antimalware",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "```bicep\n// Install Microsoft Antimalware (endpoint protection) on a VM\nparam vmName string = '<example_resource_name>'\nparam location string = '<LOCATION>'\n\nresource antimalware 'Microsoft.Compute/virtualMachines/extensions@2022-11-01' = {\n name: '${vmName}/IaaSAntimalware'\n location: location\n properties: {\n publisher: 'Microsoft.Azure.Security' // Critical: publisher for Antimalware extension\n type: 'IaaSAntimalware' // Critical: installs endpoint protection\n typeHandlerVersion: '1.5'\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Microsoft Defender for Cloud\n2. Open Recommendations and search for \"Install endpoint protection solution on virtual machines\"\n3. Select the recommendation, click Fix\n4. Select all affected VMs and click Remediate (or Apply)\n5. Wait for remediation to complete and the recommendation status to turn Healthy",
|
||||
"Terraform": "```hcl\n# Install Microsoft Antimalware (endpoint protection) on a VM\nresource \"azurerm_virtual_machine_extension\" \"<example_resource_name>\" {\n name = \"IaaSAntimalware\"\n virtual_machine_id = \"<example_resource_id>\"\n publisher = \"Microsoft.Azure.Security\" # Critical: Antimalware extension publisher\n type = \"IaaSAntimalware\" # Critical: installs endpoint protection\n type_handler_version = \"1.5\"\n}\n```"
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/install-endpoint-protection.html#",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce an **endpoint protection/EDR** baseline on every VM. Enable real-time protection, automatic updates, and alerting; use tamper protection and keep exclusions minimal. Apply **least privilege**, keep OS and agents patched, and continuously monitor coverage and health via Defender for Cloud.",
|
||||
"Url": "https://hub.prowler.com/check/defender_assessments_vm_endpoint_protection_installed"
|
||||
"Text": "Follow Microsoft Azure documentation to install endpoint protection from the security center. Alternatively, you can employ your own endpoint protection tool for your OS.",
|
||||
"Url": ""
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"vulnerabilities"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Endpoint protection will incur an additional cost to you."
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "defender_attack_path_notifications_properly_configured",
|
||||
"CheckTitle": "Security contact has attack path email notifications enabled at or above the configured minimum risk level",
|
||||
"CheckTitle": "Ensure that email notifications for attack paths are enabled with minimal risk level",
|
||||
"CheckType": [],
|
||||
"ServiceName": "defender",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.resources/subscriptions",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AzureEmailNotifications",
|
||||
"ResourceGroup": "monitoring",
|
||||
"Description": "**Microsoft Defender for Cloud** attack path email notifications are configured per subscription with a defined **minimal risk level**, and the setting is present and meets the required threshold.",
|
||||
"Risk": "Without alerts on **exploitable attack paths**, security teams lose visibility, enabling **lateral movement**, **privilege escalation**, and **data exfiltration** before containment, degrading confidentiality, integrity, and availability.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/enable-attack-path-notifications.html"
|
||||
],
|
||||
"Description": "Ensure that Microsoft Defender for Cloud is configured to send email notifications for attack paths identified in the Azure subscription with an appropriate minimal risk level.",
|
||||
"Risk": "If attack path notifications are not enabled, security teams may not be promptly informed about exploitable attack sequences, increasing the risk of delayed mitigation and potential breaches.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method put --uri https://management.azure.com/subscriptions/<SUBSCRIPTION_ID>/providers/Microsoft.Security/securityContacts/default?api-version=2020-01-01-preview --body '{\"properties\":{\"emails\":\"admin@example.com\",\"attackPathNotifications\":{\"state\":\"On\",\"minimalRiskLevel\":\"Low\"}}}'",
|
||||
"NativeIaC": "```bicep\n// Enable attack path email notifications at minimal risk level\nresource securityContact 'Microsoft.Security/securityContacts@2020-01-01-preview' = {\n name: 'default'\n properties: {\n emails: 'admin@example.com'\n attackPathNotifications: {\n state: 'On' // CRITICAL: enables attack path email notifications\n minimalRiskLevel: 'Low' // CRITICAL: sets minimal risk level to pass the check\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Microsoft Defender for Cloud > Environment settings\n2. Select the target subscription\n3. Open Email notifications\n4. Enable \"Notify about attack paths with the following risk level (or higher)\"\n5. Set Risk level to Low (or your configured minimum)\n6. Click Save",
|
||||
"Terraform": "```hcl\n# Enable attack path email notifications at minimal risk level\nresource \"azapi_resource\" \"<example_resource_name>\" {\n type = \"Microsoft.Security/securityContacts@2020-01-01-preview\"\n name = \"default\"\n body = jsonencode({\n properties = {\n emails = \"admin@example.com\"\n attackPathNotifications = {\n state = \"On\" # CRITICAL: enables attack path email notifications\n minimalRiskLevel = \"Low\" # CRITICAL: sets minimal risk level to pass the check\n }\n }\n })\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable and maintain **attack path notifications** with a minimal risk level at or above your tolerance (e.g., `High`). Send to monitored, role-based recipients. Apply **defense in depth** by integrating alerts with central monitoring and automation for prompt triage.",
|
||||
"Url": "https://hub.prowler.com/check/defender_attack_path_notifications_properly_configured"
|
||||
"Text": "Enable attack path email notifications in Microsoft Defender for Cloud to ensure that security teams are notified when potential attack paths are identified. Configure the minimal risk level as appropriate for your organization.",
|
||||
"Url": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"logging"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "defender_auto_provisioning_log_analytics_agent_vms_on",
|
||||
"CheckTitle": "Defender auto-provisioning of Log Analytics agent for Azure VMs is enabled",
|
||||
"CheckTitle": "Ensure that Auto provisioning of 'Log Analytics agent for Azure VMs' is Set to 'On'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "defender",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.resources/subscriptions",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AzureDefenderPlan",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Microsoft Defender for Cloud** auto-provisioning of the **Log Analytics agent** to Azure VMs is configured to `On` at the subscription level",
|
||||
"Risk": "Without automatic agent deployment, some VMs lack security telemetry, creating **blind spots** for vulnerabilities, missing patches, and threats.\n\nAttackers can persist or move laterally unnoticed, undermining **confidentiality** and **integrity**, while delayed detection hampers effective response.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/defender-for-cloud/data-security",
|
||||
"https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/SecurityCenter/automatic-provisioning-of-monitoring-agent.html",
|
||||
"https://learn.microsoft.com/en-us/azure/defender-for-cloud/monitoring-components"
|
||||
],
|
||||
"Description": "Ensure that Auto provisioning of 'Log Analytics agent for Azure VMs' is Set to 'On'. The Microsoft Monitoring Agent scans for various security-related configurations and events such as system updates, OS vulnerabilities, endpoint protection, and provides alerts.",
|
||||
"Risk": "Missing critical security information about your Azure VMs, such as security alerts, security recommendations, and change tracking.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-data-security",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az security auto-provisioning-setting update --name default --auto-provision On",
|
||||
"NativeIaC": "```bicep\n// Enable Defender auto-provisioning of Log Analytics agent at subscription scope\ntargetScope = 'subscription'\n\nresource autoProv 'Microsoft.Security/autoProvisioningSettings@2017-08-01-preview' = {\n name: 'default'\n properties: {\n autoProvision: 'On' // Critical: turns auto-provisioning ON for the subscription\n }\n}\n```",
|
||||
"Other": "1. In the Azure portal, open Microsoft Defender for Cloud\n2. Select Environment settings, then choose your subscription\n3. Open Auto provisioning\n4. Set Auto-provisioning of Log Analytics agent to On\n5. Click Save",
|
||||
"Terraform": "```hcl\n# Enable Defender auto-provisioning of Log Analytics agent\nresource \"azurerm_security_center_auto_provisioning\" \"<example_resource_name>\" {\n auto_provision = \"On\" # Critical: turns auto-provisioning ON\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/SecurityCenter/automatic-provisioning-of-monitoring-agent.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set **Defender for Cloud auto-provisioning** to `On` so all VMs receive the monitoring agent consistently.\n\nApply **defense in depth** by enforcing coverage for new and existing machines, standardizing workspaces, and auditing enrollment. Use **least privilege** for data access and integrate with endpoint protection and vulnerability assessment.",
|
||||
"Url": "https://hub.prowler.com/check/defender_auto_provisioning_log_analytics_agent_vms_on"
|
||||
"Text": "Ensure comprehensive visibility into possible security vulnerabilities, including missing updates, misconfigured operating system security settings, and active threats, allowing for timely mitigation and improved overall security posture",
|
||||
"Url": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/monitoring-components"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"logging"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||