Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 606aa3bdfe | |||
| f15cf20b4f | |||
| 366f10cf0c | |||
| 6bba654059 | |||
| 6d94f0fcc3 | |||
| b8da7c9619 | |||
| 6d235278dc | |||
| 9285ad3569 | |||
| 88caa9c198 | |||
| cb4892dbe3 | |||
| 5c4386df5f | |||
| fd05080d12 | |||
| 4ce82d831a | |||
| 4d47e6c2f1 | |||
| 8dc6b3e2a3 | |||
| e1f70321c8 | |||
| d016039b18 | |||
| ac013ec6fc | |||
| 4ebded6ab1 | |||
| 770269772a | |||
| ab18ddb81a | |||
| cda7f89091 | |||
| 658ae755ae | |||
| 486719737b | |||
| cb9ab03778 | |||
| 96a2262730 | |||
| 69818abdd0 | |||
| d447bdfe54 | |||
| b5095f5dc7 | |||
| 9fe71d1046 | |||
| 547c53e07c | |||
| e1900fc776 | |||
| 3c0cb3cd58 | |||
| e66c9864f5 | |||
| b1f9971617 | |||
| d01f399cb2 | |||
| 2535b55951 | |||
| 0f55d6e21d | |||
| afb666e0da | |||
| 13cd882ed2 | |||
| f65879346b | |||
| 013f2e5d32 | |||
| bcaa95f973 | |||
| 625dd37fd4 | |||
| fee2f84b89 | |||
| 08730b4eb5 | |||
| c183a2a89a |
@@ -66,7 +66,7 @@ NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST=apoc.*
|
||||
NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED=apoc.*
|
||||
NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
|
||||
# Neo4j Prowler settings
|
||||
ATTACK_PATHS_FINDINGS_BATCH_SIZE=1000
|
||||
ATTACK_PATHS_BATCH_SIZE=1000
|
||||
|
||||
# Celery-Prowler task settings
|
||||
TASK_RETRY_DELAY_SECONDS=0.1
|
||||
|
||||
@@ -57,6 +57,11 @@ provider/cloudflare:
|
||||
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
|
||||
- any-glob-to-any-file: "tests/providers/cloudflare/**"
|
||||
|
||||
provider/openstack:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/openstack/**"
|
||||
- any-glob-to-any-file: "tests/providers/openstack/**"
|
||||
|
||||
github_actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ".github/workflows/*"
|
||||
@@ -77,6 +82,7 @@ mutelist:
|
||||
- any-glob-to-any-file: "prowler/providers/oraclecloud/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "prowler/providers/alibabacloud/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "prowler/providers/cloudflare/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "prowler/providers/openstack/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/aws/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/azure/lib/mutelist/**"
|
||||
@@ -87,6 +93,7 @@ mutelist:
|
||||
- any-glob-to-any-file: "tests/providers/oraclecloud/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/alibabacloud/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/cloudflare/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**"
|
||||
|
||||
integration/s3:
|
||||
- changed-files:
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Impact Analysis Script
|
||||
|
||||
Analyzes changed files and determines which tests need to run.
|
||||
Outputs GitHub Actions compatible outputs.
|
||||
|
||||
Usage:
|
||||
python test-impact.py <changed_files...>
|
||||
python test-impact.py --from-stdin # Read files from stdin (one per line)
|
||||
|
||||
Outputs (for GitHub Actions):
|
||||
- run-all: "true" if critical paths changed
|
||||
- sdk-tests: Space-separated list of SDK test paths
|
||||
- api-tests: Space-separated list of API test paths
|
||||
- ui-e2e: Space-separated list of UI E2E test paths
|
||||
- modules: Comma-separated list of affected module names
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Load test-impact.yml configuration."""
|
||||
config_path = Path(__file__).parent.parent / "test-impact.yml"
|
||||
with open(config_path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def matches_pattern(file_path: str, pattern: str) -> bool:
|
||||
"""Check if file path matches a glob pattern."""
|
||||
# Normalize paths
|
||||
file_path = file_path.strip("/")
|
||||
pattern = pattern.strip("/")
|
||||
|
||||
# Handle ** patterns
|
||||
if "**" in pattern:
|
||||
# Convert glob pattern to work with fnmatch
|
||||
# e.g., "prowler/lib/**" matches "prowler/lib/check/foo.py"
|
||||
base = pattern.replace("/**", "")
|
||||
if file_path.startswith(base):
|
||||
return True
|
||||
# Also try standard fnmatch
|
||||
return fnmatch.fnmatch(file_path, pattern)
|
||||
|
||||
return fnmatch.fnmatch(file_path, pattern)
|
||||
|
||||
|
||||
def filter_ignored_files(
|
||||
changed_files: list[str], ignored_paths: list[str]
|
||||
) -> list[str]:
|
||||
"""Filter out files that match ignored patterns."""
|
||||
filtered = []
|
||||
for file_path in changed_files:
|
||||
is_ignored = False
|
||||
for pattern in ignored_paths:
|
||||
if matches_pattern(file_path, pattern):
|
||||
print(f" [IGNORED] {file_path} matches {pattern}", file=sys.stderr)
|
||||
is_ignored = True
|
||||
break
|
||||
if not is_ignored:
|
||||
filtered.append(file_path)
|
||||
return filtered
|
||||
|
||||
|
||||
def check_critical_paths(changed_files: list[str], critical_paths: list[str]) -> bool:
|
||||
"""Check if any changed file matches critical paths."""
|
||||
for file_path in changed_files:
|
||||
for pattern in critical_paths:
|
||||
if matches_pattern(file_path, pattern):
|
||||
print(f" [CRITICAL] {file_path} matches {pattern}", file=sys.stderr)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_affected_modules(
|
||||
changed_files: list[str], modules: list[dict]
|
||||
) -> dict[str, dict]:
|
||||
"""Find which modules are affected by changed files."""
|
||||
affected = {}
|
||||
|
||||
for file_path in changed_files:
|
||||
for module in modules:
|
||||
module_name = module["name"]
|
||||
match_patterns = module.get("match", [])
|
||||
|
||||
for pattern in match_patterns:
|
||||
if matches_pattern(file_path, pattern):
|
||||
if module_name not in affected:
|
||||
affected[module_name] = {
|
||||
"tests": set(),
|
||||
"e2e": set(),
|
||||
"matched_files": [],
|
||||
}
|
||||
affected[module_name]["matched_files"].append(file_path)
|
||||
|
||||
# Add test patterns
|
||||
for test_pattern in module.get("tests", []):
|
||||
affected[module_name]["tests"].add(test_pattern)
|
||||
|
||||
# Add E2E patterns
|
||||
for e2e_pattern in module.get("e2e", []):
|
||||
affected[module_name]["e2e"].add(e2e_pattern)
|
||||
|
||||
break # File matched this module, move to next file
|
||||
|
||||
return affected
|
||||
|
||||
|
||||
def categorize_tests(
|
||||
affected_modules: dict[str, dict],
|
||||
) -> tuple[set[str], set[str], set[str]]:
|
||||
"""Categorize tests into SDK, API, and UI E2E."""
|
||||
sdk_tests = set()
|
||||
api_tests = set()
|
||||
ui_e2e = set()
|
||||
|
||||
for module_name, data in affected_modules.items():
|
||||
for test_path in data["tests"]:
|
||||
if test_path.startswith("tests/"):
|
||||
sdk_tests.add(test_path)
|
||||
elif test_path.startswith("api/"):
|
||||
api_tests.add(test_path)
|
||||
|
||||
for e2e_path in data["e2e"]:
|
||||
ui_e2e.add(e2e_path)
|
||||
|
||||
return sdk_tests, api_tests, ui_e2e
|
||||
|
||||
|
||||
def set_github_output(name: str, value: str):
|
||||
"""Set GitHub Actions output."""
|
||||
github_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if github_output:
|
||||
with open(github_output, "a") as f:
|
||||
# Handle multiline values
|
||||
if "\n" in value:
|
||||
import uuid
|
||||
|
||||
delimiter = uuid.uuid4().hex
|
||||
f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
else:
|
||||
f.write(f"{name}={value}\n")
|
||||
# Print for debugging (without deprecated format)
|
||||
print(f" {name}={value}", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
# Parse arguments
|
||||
if "--from-stdin" in sys.argv:
|
||||
changed_files = [line.strip() for line in sys.stdin if line.strip()]
|
||||
else:
|
||||
changed_files = [f for f in sys.argv[1:] if f and not f.startswith("-")]
|
||||
|
||||
if not changed_files:
|
||||
print("No changed files provided", file=sys.stderr)
|
||||
set_github_output("run-all", "false")
|
||||
set_github_output("sdk-tests", "")
|
||||
set_github_output("api-tests", "")
|
||||
set_github_output("ui-e2e", "")
|
||||
set_github_output("modules", "")
|
||||
set_github_output("has-tests", "false")
|
||||
return
|
||||
|
||||
print(f"Analyzing {len(changed_files)} changed files...", file=sys.stderr)
|
||||
for f in changed_files[:10]: # Show first 10
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
if len(changed_files) > 10:
|
||||
print(f" ... and {len(changed_files) - 10} more", file=sys.stderr)
|
||||
|
||||
# Load configuration
|
||||
config = load_config()
|
||||
|
||||
# Filter out ignored files (docs, configs, etc.)
|
||||
ignored_paths = config.get("ignored", {}).get("paths", [])
|
||||
changed_files = filter_ignored_files(changed_files, ignored_paths)
|
||||
|
||||
if not changed_files:
|
||||
print("\nAll changed files are ignored (docs, configs, etc.)", file=sys.stderr)
|
||||
print("No tests needed.", file=sys.stderr)
|
||||
set_github_output("run-all", "false")
|
||||
set_github_output("sdk-tests", "")
|
||||
set_github_output("api-tests", "")
|
||||
set_github_output("ui-e2e", "")
|
||||
set_github_output("modules", "none-ignored")
|
||||
set_github_output("has-tests", "false")
|
||||
return
|
||||
|
||||
print(
|
||||
f"\n{len(changed_files)} files remain after filtering ignored paths",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Check critical paths
|
||||
critical_paths = config.get("critical", {}).get("paths", [])
|
||||
if check_critical_paths(changed_files, critical_paths):
|
||||
print("\nCritical path changed - running ALL tests", file=sys.stderr)
|
||||
set_github_output("run-all", "true")
|
||||
set_github_output("sdk-tests", "tests/")
|
||||
set_github_output("api-tests", "api/src/backend/")
|
||||
set_github_output("ui-e2e", "ui/tests/")
|
||||
set_github_output("modules", "all")
|
||||
set_github_output("has-tests", "true")
|
||||
return
|
||||
|
||||
# Find affected modules
|
||||
modules = config.get("modules", [])
|
||||
affected = find_affected_modules(changed_files, modules)
|
||||
|
||||
if not affected:
|
||||
print("\nNo test-mapped modules affected", file=sys.stderr)
|
||||
set_github_output("run-all", "false")
|
||||
set_github_output("sdk-tests", "")
|
||||
set_github_output("api-tests", "")
|
||||
set_github_output("ui-e2e", "")
|
||||
set_github_output("modules", "")
|
||||
set_github_output("has-tests", "false")
|
||||
return
|
||||
|
||||
# Report affected modules
|
||||
print(f"\nAffected modules: {len(affected)}", file=sys.stderr)
|
||||
for module_name, data in affected.items():
|
||||
print(f" [{module_name}]", file=sys.stderr)
|
||||
for f in data["matched_files"][:3]:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
if len(data["matched_files"]) > 3:
|
||||
print(
|
||||
f" ... and {len(data['matched_files']) - 3} more files",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Categorize tests
|
||||
sdk_tests, api_tests, ui_e2e = categorize_tests(affected)
|
||||
|
||||
# Output results
|
||||
print("\nTest paths to run:", file=sys.stderr)
|
||||
print(f" SDK: {sdk_tests or 'none'}", file=sys.stderr)
|
||||
print(f" API: {api_tests or 'none'}", file=sys.stderr)
|
||||
print(f" E2E: {ui_e2e or 'none'}", file=sys.stderr)
|
||||
|
||||
set_github_output("run-all", "false")
|
||||
set_github_output("sdk-tests", " ".join(sorted(sdk_tests)))
|
||||
set_github_output("api-tests", " ".join(sorted(api_tests)))
|
||||
set_github_output("ui-e2e", " ".join(sorted(ui_e2e)))
|
||||
set_github_output("modules", ",".join(sorted(affected.keys())))
|
||||
set_github_output(
|
||||
"has-tests", "true" if (sdk_tests or api_tests or ui_e2e) else "false"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,402 @@
|
||||
# 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/**
|
||||
@@ -0,0 +1,91 @@
|
||||
name: 'SDK: Check Duplicate Test Names'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-duplicate-test-names:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Check for duplicate test names across providers
|
||||
run: |
|
||||
python3 << 'EOF'
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
def find_duplicate_test_names():
|
||||
"""Find test files with the same name across different providers."""
|
||||
tests_dir = Path("tests/providers")
|
||||
|
||||
if not tests_dir.exists():
|
||||
print("tests/providers directory not found")
|
||||
sys.exit(0)
|
||||
|
||||
# Dictionary: filename -> list of (provider, full_path)
|
||||
test_files = defaultdict(list)
|
||||
|
||||
# Find all *_test.py files
|
||||
for test_file in tests_dir.rglob("*_test.py"):
|
||||
relative_path = test_file.relative_to(tests_dir)
|
||||
provider = relative_path.parts[0]
|
||||
filename = test_file.name
|
||||
test_files[filename].append((provider, str(test_file)))
|
||||
|
||||
# Find duplicates (files appearing in multiple providers)
|
||||
duplicates = {
|
||||
filename: locations
|
||||
for filename, locations in test_files.items()
|
||||
if len(set(loc[0] for loc in locations)) > 1
|
||||
}
|
||||
|
||||
if not duplicates:
|
||||
print("No duplicate test file names found across providers.")
|
||||
print("All test names are unique within the repository.")
|
||||
sys.exit(0)
|
||||
|
||||
# Report duplicates
|
||||
print("::error::Duplicate test file names found across providers!")
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("DUPLICATE TEST NAMES DETECTED")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("The following test files have the same name in multiple providers.")
|
||||
print("Please rename YOUR new test file by adding the provider prefix.")
|
||||
print()
|
||||
print("Example: 'kms_service_test.py' -> 'oraclecloud_kms_service_test.py'")
|
||||
print()
|
||||
|
||||
for filename, locations in sorted(duplicates.items()):
|
||||
print(f"### {filename}")
|
||||
print(f" Found in {len(locations)} providers:")
|
||||
for provider, path in sorted(locations):
|
||||
print(f" - {provider}: {path}")
|
||||
print()
|
||||
print(f" Suggested fix: Rename your new file to '<provider>_{filename}'")
|
||||
print()
|
||||
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("See: tests/providers/TESTING.md for naming conventions.")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
find_duplicate_test_names()
|
||||
EOF
|
||||
@@ -414,6 +414,30 @@ jobs:
|
||||
flags: prowler-py${{ matrix.python-version }}-oraclecloud
|
||||
files: ./oraclecloud_coverage.xml
|
||||
|
||||
# OpenStack Provider
|
||||
- name: Check if OpenStack files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-openstack
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/openstack/**
|
||||
./tests/**/openstack/**
|
||||
./poetry.lock
|
||||
|
||||
- name: Run OpenStack tests
|
||||
if: steps.changed-openstack.outputs.any_changed == 'true'
|
||||
run: poetry run pytest -n auto --cov=./prowler/providers/openstack --cov-report=xml:openstack_coverage.xml tests/providers/openstack
|
||||
|
||||
- name: Upload OpenStack coverage to Codecov
|
||||
if: steps.changed-openstack.outputs.any_changed == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: prowler-py${{ matrix.python-version }}-openstack
|
||||
files: ./openstack_coverage.xml
|
||||
|
||||
# Lib
|
||||
- name: Check if Lib files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Test Impact Analysis
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
run-all:
|
||||
description: "Whether to run all tests (critical path changed)"
|
||||
value: ${{ jobs.analyze.outputs.run-all }}
|
||||
sdk-tests:
|
||||
description: "SDK test paths to run"
|
||||
value: ${{ jobs.analyze.outputs.sdk-tests }}
|
||||
api-tests:
|
||||
description: "API test paths to run"
|
||||
value: ${{ jobs.analyze.outputs.api-tests }}
|
||||
ui-e2e:
|
||||
description: "UI E2E test paths to run"
|
||||
value: ${{ jobs.analyze.outputs.ui-e2e }}
|
||||
modules:
|
||||
description: "Comma-separated list of affected modules"
|
||||
value: ${{ jobs.analyze.outputs.modules }}
|
||||
has-tests:
|
||||
description: "Whether there are any tests to run"
|
||||
value: ${{ jobs.analyze.outputs.has-tests }}
|
||||
has-sdk-tests:
|
||||
description: "Whether there are SDK tests to run"
|
||||
value: ${{ jobs.analyze.outputs.has-sdk-tests }}
|
||||
has-api-tests:
|
||||
description: "Whether there are API tests to run"
|
||||
value: ${{ jobs.analyze.outputs.has-api-tests }}
|
||||
has-ui-e2e:
|
||||
description: "Whether there are UI E2E tests to run"
|
||||
value: ${{ jobs.analyze.outputs.has-ui-e2e }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
run-all: ${{ steps.impact.outputs.run-all }}
|
||||
sdk-tests: ${{ steps.impact.outputs.sdk-tests }}
|
||||
api-tests: ${{ steps.impact.outputs.api-tests }}
|
||||
ui-e2e: ${{ steps.impact.outputs.ui-e2e }}
|
||||
modules: ${{ steps.impact.outputs.modules }}
|
||||
has-tests: ${{ steps.impact.outputs.has-tests }}
|
||||
has-sdk-tests: ${{ steps.set-flags.outputs.has-sdk-tests }}
|
||||
has-api-tests: ${{ steps.set-flags.outputs.has-api-tests }}
|
||||
has-ui-e2e: ${{ steps.set-flags.outputs.has-ui-e2e }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Analyze test impact
|
||||
id: impact
|
||||
run: |
|
||||
echo "Changed files:"
|
||||
echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n'
|
||||
echo ""
|
||||
python .github/scripts/test-impact.py ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
|
||||
- name: Set convenience flags
|
||||
id: set-flags
|
||||
run: |
|
||||
if [[ -n "${{ steps.impact.outputs.sdk-tests }}" ]]; then
|
||||
echo "has-sdk-tests=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has-sdk-tests=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [[ -n "${{ steps.impact.outputs.api-tests }}" ]]; then
|
||||
echo "has-api-tests=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has-api-tests=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [[ -n "${{ steps.impact.outputs.ui-e2e }}" ]]; then
|
||||
echo "has-ui-e2e=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has-ui-e2e=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ steps.impact.outputs.run-all }}" == "true" ]]; then
|
||||
echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`${{ steps.impact.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SDK Tests | \`${{ steps.impact.outputs.sdk-tests || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| API Tests | \`${{ steps.impact.outputs.api-tests || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| UI E2E | \`${{ steps.impact.outputs.ui-e2e || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -1,4 +1,8 @@
|
||||
name: UI - E2E Tests
|
||||
name: UI - E2E Tests (Optimized)
|
||||
|
||||
# This is an optimized version that runs only relevant E2E tests
|
||||
# based on changed files. Falls back to running all tests if
|
||||
# critical paths are changed or if impact analysis fails.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -6,13 +10,23 @@ on:
|
||||
- master
|
||||
- "v5.*"
|
||||
paths:
|
||||
- '.github/workflows/ui-e2e-tests.yml'
|
||||
- '.github/workflows/ui-e2e-tests-v2.yml'
|
||||
- '.github/test-impact.yml'
|
||||
- 'ui/**'
|
||||
- 'api/**' # API changes can affect UI E2E
|
||||
|
||||
jobs:
|
||||
|
||||
e2e-tests:
|
||||
# First, analyze which tests need to run
|
||||
impact-analysis:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
uses: ./.github/workflows/test-impact-analysis.yml
|
||||
|
||||
# Run E2E tests based on impact analysis
|
||||
e2e-tests:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AUTH_SECRET: 'fallback-ci-secret-for-testing'
|
||||
@@ -51,80 +65,95 @@ 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: |
|
||||
# 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
|
||||
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 AWS SDK Default Adding Provider
|
||||
|
||||
- name: Add AWS credentials for testing
|
||||
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 # 5 minutes max
|
||||
timeout=150
|
||||
elapsed=0
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then
|
||||
echo "Prowler API is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for prowler-api... (${elapsed}s elapsed)"
|
||||
echo "Waiting... (${elapsed}s elapsed)"
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
echo "Timeout waiting for prowler-api to start"
|
||||
echo "Timeout waiting for prowler-api"
|
||||
exit 1
|
||||
- name: Load database fixtures for E2E tests
|
||||
|
||||
- name: Load database fixtures
|
||||
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 environment
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
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:
|
||||
@@ -136,12 +165,15 @@ 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
|
||||
@@ -150,13 +182,36 @@ 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: pnpm run test:e2e
|
||||
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
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: failure()
|
||||
@@ -164,9 +219,27 @@ 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
|
||||
echo "Cleanup completed"
|
||||
|
||||
# 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
|
||||
@@ -62,4 +62,4 @@ We strive to resolve all problems as quickly as possible, and we would like to p
|
||||
|
||||
---
|
||||
|
||||
For more information about our security policies, please refer to our [Security](https://docs.prowler.com/projects/prowler-open-source/en/latest/security/) section in our documentation.
|
||||
For more information about our security policies, please refer to our [Security](https://docs.prowler.com/security) section in our documentation.
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.19.0] (Prowler UNRELEASED)
|
||||
## [1.19.2] (Prowler v5.18.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- SAML role mapping now prevents removing the last MANAGE_ACCOUNT user [(#10007)](https://github.com/prowler-cloud/prowler/pull/10007)
|
||||
|
||||
---
|
||||
|
||||
## [1.19.0] (Prowler v5.18.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Cloudflare provider support [(#9907)](https://github.com/prowler-cloud/prowler/pull/9907)
|
||||
- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885)
|
||||
- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864)
|
||||
- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444)
|
||||
@@ -15,11 +24,9 @@ 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)
|
||||
|
||||
---
|
||||
|
||||
## [1.18.2] (Prowler UNRELEASED)
|
||||
- Attack Paths: Modified the behaviour of the Cartography scans to use the same Neo4j database per tenant, instead of individual databases per scans [(#9955)](https://github.com/prowler-cloud/prowler/pull/9955)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ requires = ["poetry-core"]
|
||||
[project]
|
||||
authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
|
||||
dependencies = [
|
||||
"celery[pytest] (>=5.4.0,<6.0.0)",
|
||||
"celery (>=5.4.0,<6.0.0)",
|
||||
"dj-rest-auth[with_social,jwt] (==7.0.1)",
|
||||
"django (==5.1.15)",
|
||||
"django-allauth[saml] (>=65.13.0,<66.0.0)",
|
||||
@@ -24,7 +24,7 @@ dependencies = [
|
||||
"drf-spectacular-jsonapi==0.5.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==5.3.2",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.18",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
|
||||
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
|
||||
@@ -37,7 +37,7 @@ dependencies = [
|
||||
"matplotlib (>=3.10.6,<4.0.0)",
|
||||
"reportlab (>=4.4.4,<5.0.0)",
|
||||
"neo4j (<6.0.0)",
|
||||
"cartography @ git+https://github.com/prowler-cloud/cartography@master",
|
||||
"cartography @ git+https://github.com/prowler-cloud/cartography@0.126.1",
|
||||
"gevent (>=25.9.1,<26.0.0)",
|
||||
"werkzeug (>=3.1.4)",
|
||||
"sqlparse (>=0.5.4)",
|
||||
@@ -49,7 +49,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.19.0"
|
||||
version = "1.19.2"
|
||||
|
||||
[project.scripts]
|
||||
celery = "src.backend.config.settings.celery"
|
||||
@@ -59,6 +59,7 @@ bandit = "1.7.9"
|
||||
coverage = "7.5.4"
|
||||
django-silk = "5.3.2"
|
||||
docker = "7.1.0"
|
||||
filelock = "3.20.3"
|
||||
freezegun = "1.5.1"
|
||||
marshmallow = ">=3.15.0,<4.0.0"
|
||||
mypy = "1.10.1"
|
||||
@@ -71,6 +72,5 @@ pytest-randomly = "3.15.0"
|
||||
pytest-xdist = "3.6.1"
|
||||
ruff = "0.5.0"
|
||||
safety = "3.7.0"
|
||||
filelock = "3.20.3"
|
||||
vulture = "2.14"
|
||||
tqdm = "4.67.1"
|
||||
vulture = "2.14"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from api.attack_paths.query_definitions import (
|
||||
from api.attack_paths.queries import (
|
||||
AttackPathsQueryDefinition,
|
||||
AttackPathsQueryParameterDefinition,
|
||||
get_queries_for_provider,
|
||||
get_query_by_id,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AttackPathsQueryDefinition",
|
||||
"AttackPathsQueryParameterDefinition",
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import atexit
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
from uuid import UUID
|
||||
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from tasks.jobs.attack_paths.config import BATCH_SIZE, PROVIDER_RESOURCE_LABEL
|
||||
|
||||
# Without this Celery goes crazy with Neo4j logging
|
||||
logging.getLogger("neo4j").setLevel(logging.ERROR)
|
||||
@@ -83,7 +86,8 @@ def get_session(database: str | None = None) -> Iterator[RetryableSession]:
|
||||
yield session_wrapper
|
||||
|
||||
except neo4j.exceptions.Neo4jError as exc:
|
||||
raise GraphDatabaseQueryException(message=exc.message, code=exc.code)
|
||||
message = exc.message if exc.message is not None else str(exc)
|
||||
raise GraphDatabaseQueryException(message=message, code=exc.code)
|
||||
|
||||
finally:
|
||||
if session_wrapper is not None:
|
||||
@@ -105,24 +109,41 @@ def drop_database(database: str) -> None:
|
||||
session.run(query)
|
||||
|
||||
|
||||
def drop_subgraph(database: str, root_node_label: str, root_node_id: str) -> int:
|
||||
query = """
|
||||
MATCH (a:__ROOT_NODE_LABEL__ {id: $root_node_id})
|
||||
CALL apoc.path.subgraphNodes(a, {})
|
||||
YIELD node
|
||||
DETACH DELETE node
|
||||
RETURN COUNT(node) AS deleted_nodes_count
|
||||
""".replace("__ROOT_NODE_LABEL__", root_node_label)
|
||||
parameters = {"root_node_id": root_node_id}
|
||||
def drop_subgraph(database: str, provider_id: str) -> int:
|
||||
"""
|
||||
Delete all nodes for a provider from the tenant database.
|
||||
|
||||
with get_session(database) as session:
|
||||
result = session.run(query, parameters)
|
||||
Uses batched deletion to avoid memory issues with large graphs.
|
||||
Silently returns 0 if the database doesn't exist.
|
||||
"""
|
||||
deleted_nodes = 0
|
||||
parameters = {
|
||||
"provider_id": provider_id,
|
||||
"batch_size": BATCH_SIZE,
|
||||
}
|
||||
|
||||
try:
|
||||
return result.single()["deleted_nodes_count"]
|
||||
try:
|
||||
with get_session(database) as session:
|
||||
deleted_count = 1
|
||||
while deleted_count > 0:
|
||||
result = session.run(
|
||||
f"""
|
||||
MATCH (n:{PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}})
|
||||
WITH n LIMIT $batch_size
|
||||
DETACH DELETE n
|
||||
RETURN COUNT(n) AS deleted_nodes_count
|
||||
""",
|
||||
parameters,
|
||||
)
|
||||
deleted_count = result.single().get("deleted_nodes_count", 0)
|
||||
deleted_nodes += deleted_count
|
||||
|
||||
except neo4j.exceptions.ResultConsumedError:
|
||||
return 0 # As there are no nodes to delete, the result is empty
|
||||
except GraphDatabaseQueryException as exc:
|
||||
if exc.code == "Neo.ClientError.Database.DatabaseNotFound":
|
||||
return 0
|
||||
raise
|
||||
|
||||
return deleted_nodes
|
||||
|
||||
|
||||
def clear_cache(database: str) -> None:
|
||||
@@ -137,12 +158,11 @@ def clear_cache(database: str) -> None:
|
||||
|
||||
|
||||
# Neo4j functions related to Prowler + Cartography
|
||||
DATABASE_NAME_TEMPLATE = "db-{attack_paths_scan_id}"
|
||||
|
||||
|
||||
def get_database_name(attack_paths_scan_id: UUID) -> str:
|
||||
attack_paths_scan_id_str = str(attack_paths_scan_id).lower()
|
||||
return DATABASE_NAME_TEMPLATE.format(attack_paths_scan_id=attack_paths_scan_id_str)
|
||||
def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str:
|
||||
prefix = "tmp-scan" if temporary else "tenant"
|
||||
return f"db-{prefix}-{str(entity_id).lower()}"
|
||||
|
||||
|
||||
# Exceptions
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from api.attack_paths.queries.types import (
|
||||
AttackPathsQueryDefinition,
|
||||
AttackPathsQueryParameterDefinition,
|
||||
)
|
||||
from api.attack_paths.queries.registry import (
|
||||
get_queries_for_provider,
|
||||
get_query_by_id,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AttackPathsQueryDefinition",
|
||||
"AttackPathsQueryParameterDefinition",
|
||||
"get_queries_for_provider",
|
||||
"get_query_by_id",
|
||||
]
|
||||
@@ -0,0 +1,695 @@
|
||||
from api.attack_paths.queries.types import (
|
||||
AttackPathsQueryDefinition,
|
||||
AttackPathsQueryParameterDefinition,
|
||||
)
|
||||
from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
|
||||
|
||||
|
||||
# Privilege Escalation Queries (based on pathfinding.cloud research)
|
||||
# https://github.com/DataDog/pathfinding.cloud
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
|
||||
id="aws-internet-exposed-ec2-sensitive-s3-access",
|
||||
name="Identify internet-exposed EC2 instances with sensitive S3 access",
|
||||
description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
|
||||
YIELD node AS internet
|
||||
|
||||
MATCH path_s3 = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)--(t:AWSTag)
|
||||
WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value)
|
||||
|
||||
MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)
|
||||
WHERE ec2.exposed_internet = true
|
||||
AND ipi.toport = 22
|
||||
|
||||
MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name)
|
||||
AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*')
|
||||
|
||||
MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole)
|
||||
|
||||
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2)
|
||||
YIELD rel AS can_access
|
||||
|
||||
UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
|
||||
""",
|
||||
parameters=[
|
||||
AttackPathsQueryParameterDefinition(
|
||||
name="tag_key",
|
||||
label="Tag key",
|
||||
description="Tag key to filter the S3 bucket, e.g. DataClassification.",
|
||||
placeholder="DataClassification",
|
||||
),
|
||||
AttackPathsQueryParameterDefinition(
|
||||
name="tag_value",
|
||||
label="Tag value",
|
||||
description="Tag value to filter the S3 bucket, e.g. Sensitive.",
|
||||
placeholder="Sensitive",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Basic Resource Queries
|
||||
# ----------------------
|
||||
|
||||
AWS_RDS_INSTANCES = AttackPathsQueryDefinition(
|
||||
id="aws-rds-instances",
|
||||
name="Identify provisioned RDS instances",
|
||||
description="List the selected AWS account alongside the RDS instances it owns.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance)
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition(
|
||||
id="aws-rds-unencrypted-storage",
|
||||
name="Identify RDS instances without storage encryption",
|
||||
description="Find RDS instances with storage encryption disabled within the selected account.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance)
|
||||
WHERE rds.storage_encrypted = false
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition(
|
||||
id="aws-s3-anonymous-access-buckets",
|
||||
name="Identify S3 buckets with anonymous access",
|
||||
description="Find S3 buckets that allow anonymous access within the selected account.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)
|
||||
WHERE s3.anonymous_access = true
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition(
|
||||
id="aws-iam-statements-allow-all-actions",
|
||||
name="Identify IAM statements that allow all actions",
|
||||
description="Find IAM policy statements that allow all actions via '*' within the selected account.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = 'Allow'
|
||||
AND any(x IN stmt.action WHERE x = '*')
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition(
|
||||
id="aws-iam-statements-allow-delete-policy",
|
||||
name="Identify IAM statements that allow iam:DeletePolicy",
|
||||
description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = 'Allow'
|
||||
AND any(x IN stmt.action WHERE x = "iam:DeletePolicy")
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition(
|
||||
id="aws-iam-statements-allow-create-actions",
|
||||
name="Identify IAM statements that allow create actions",
|
||||
description="Find IAM policy statements that allow actions containing 'create' within the selected account.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = "Allow"
|
||||
AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create")
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
|
||||
# Network Exposure Queries
|
||||
# ------------------------
|
||||
|
||||
AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition(
|
||||
id="aws-ec2-instances-internet-exposed",
|
||||
name="Identify internet-exposed EC2 instances",
|
||||
description="Find EC2 instances flagged as exposed to the internet within the selected account.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
|
||||
YIELD node AS internet
|
||||
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)
|
||||
WHERE ec2.exposed_internet = true
|
||||
|
||||
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2)
|
||||
YIELD rel AS can_access
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition(
|
||||
id="aws-security-groups-open-internet-facing",
|
||||
name="Identify internet-facing resources with open security groups",
|
||||
description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
|
||||
YIELD node AS internet
|
||||
|
||||
// Match EC2 instances that are internet-exposed with open security groups (0.0.0.0/0)
|
||||
MATCH path_ec2 = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange)
|
||||
WHERE ec2.exposed_internet = true
|
||||
AND ir.range = "0.0.0.0/0"
|
||||
|
||||
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2)
|
||||
YIELD rel AS can_access
|
||||
|
||||
UNWIND nodes(path_ec2) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_ec2, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition(
|
||||
id="aws-classic-elb-internet-exposed",
|
||||
name="Identify internet-exposed Classic Load Balancers",
|
||||
description="Find Classic Load Balancers exposed to the internet along with their listeners.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
|
||||
YIELD node AS internet
|
||||
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener)
|
||||
WHERE elb.exposed_internet = true
|
||||
|
||||
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, elb)
|
||||
YIELD rel AS can_access
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition(
|
||||
id="aws-elbv2-internet-exposed",
|
||||
name="Identify internet-exposed ELBv2 load balancers",
|
||||
description="Find ELBv2 load balancers exposed to the internet along with their listeners.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
|
||||
YIELD node AS internet
|
||||
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener)
|
||||
WHERE elbv2.exposed_internet = true
|
||||
|
||||
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, elbv2)
|
||||
YIELD rel AS can_access
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition(
|
||||
id="aws-public-ip-resource-lookup",
|
||||
name="Identify resources by public IP address",
|
||||
description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}})
|
||||
YIELD node AS internet
|
||||
|
||||
CALL () {{
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:EC2PrivateIp)-[q]-(y)
|
||||
WHERE x.public_ip = $ip
|
||||
RETURN path, x
|
||||
|
||||
UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:EC2Instance)-[q]-(y)
|
||||
WHERE x.publicipaddress = $ip
|
||||
RETURN path, x
|
||||
|
||||
UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:NetworkInterface)-[q]-(y)
|
||||
WHERE x.public_ip = $ip
|
||||
RETURN path, x
|
||||
|
||||
UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:ElasticIPAddress)-[q]-(y)
|
||||
WHERE x.public_ip = $ip
|
||||
RETURN path, x
|
||||
}}
|
||||
|
||||
WITH path, x, internet
|
||||
|
||||
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, x)
|
||||
YIELD rel AS can_access
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
|
||||
""",
|
||||
parameters=[
|
||||
AttackPathsQueryParameterDefinition(
|
||||
name="ip",
|
||||
label="IP address",
|
||||
description="Public IP address, e.g. 192.0.2.0.",
|
||||
placeholder="192.0.2.0",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
AWS_IAM_PRIVESC_PASSROLE_EC2 = AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-passrole-ec2",
|
||||
name="Privilege Escalation: iam:PassRole + ec2:RunInstances",
|
||||
description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service. This is a new-passrole escalation path (pathfinding.cloud: ec2-001).",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Create a single shared virtual EC2 instance node
|
||||
CALL apoc.create.vNode(['EC2Instance'], {{
|
||||
id: 'potential-ec2-passrole',
|
||||
name: 'New EC2 Instance',
|
||||
description: 'Attacker-controlled EC2 with privileged role'
|
||||
}})
|
||||
YIELD node AS ec2_node
|
||||
|
||||
// Create a single shared virtual escalation outcome node (styled like a finding)
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {{
|
||||
id: 'effective-administrator-passrole-ec2',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
}})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH ec2_node, escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)
|
||||
|
||||
// Find statements granting iam:PassRole
|
||||
MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement)
|
||||
WHERE stmt_passrole.effect = 'Allow'
|
||||
AND any(action IN stmt_passrole.action WHERE
|
||||
toLower(action) = 'iam:passrole'
|
||||
OR toLower(action) = 'iam:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find statements granting ec2:RunInstances
|
||||
MATCH path_ec2 = (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement)
|
||||
WHERE stmt_ec2.effect = 'Allow'
|
||||
AND any(action IN stmt_ec2.action WHERE
|
||||
toLower(action) = 'ec2:runinstances'
|
||||
OR toLower(action) = 'ec2:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find roles that trust EC2 service (can be passed to EC2)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WHERE target_role.arn CONTAINS $provider_uid
|
||||
// Check if principal can pass this role
|
||||
AND any(resource IN stmt_passrole.resource WHERE
|
||||
resource = '*'
|
||||
OR target_role.arn CONTAINS resource
|
||||
OR resource CONTAINS target_role.name
|
||||
)
|
||||
|
||||
// Check if target role has elevated permissions (optional, for severity assessment)
|
||||
OPTIONAL MATCH (target_role)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement)
|
||||
WHERE role_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN role_stmt.action WHERE action = '*')
|
||||
OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
CALL apoc.create.vRelationship(principal, 'CAN_LAUNCH', {{
|
||||
via: 'ec2:RunInstances + iam:PassRole'
|
||||
}}, ec2_node)
|
||||
YIELD rel AS launch_rel
|
||||
|
||||
CALL apoc.create.vRelationship(ec2_node, 'ASSUMES_ROLE', {{}}, target_role)
|
||||
YIELD rel AS assumes_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {{
|
||||
reference: 'https://pathfinding.cloud/paths/ec2-001'
|
||||
}}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
UNWIND nodes(path_principal) + nodes(path_passrole) + nodes(path_ec2) + nodes(path_target) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_principal, path_passrole, path_ec2, path_target,
|
||||
ec2_node, escalation_outcome, launch_rel, assumes_rel, grants_rel,
|
||||
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# TODO: Add ProwlerFinding nodes
|
||||
AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition(
|
||||
id="aws-glue-privesc-passrole-dev-endpoint",
|
||||
name="Privilege Escalation: Glue Dev Endpoint with PassRole",
|
||||
description="Detect principals that can escalate privileges by passing a role to a Glue development endpoint. The attacker creates a dev endpoint with an arbitrary role attached, then accesses those credentials through the endpoint.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-glue',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator (Glue)',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Principal can assume roles (up to 2 hops)
|
||||
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
|
||||
WITH escalation_outcome, principal, path_principal, path_assume,
|
||||
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
|
||||
|
||||
// Find iam:PassRole permission
|
||||
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
|
||||
WHERE passrole_stmt.effect = 'Allow'
|
||||
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
|
||||
|
||||
// Find Glue CreateDevEndpoint permission
|
||||
MATCH (effective_principal)--(glue_policy:AWSPolicy)--(glue_stmt:AWSPolicyStatement)
|
||||
WHERE glue_stmt.effect = 'Allow'
|
||||
AND any(action IN glue_stmt.action WHERE toLower(action) = 'glue:createdevendpoint' OR action = '*' OR toLower(action) = 'glue:*')
|
||||
|
||||
// Find target role with elevated permissions
|
||||
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
|
||||
WHERE target_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN target_stmt.action WHERE action = '*')
|
||||
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
// Deduplicate before creating virtual nodes
|
||||
WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role
|
||||
|
||||
// Create virtual Glue endpoint node (one per unique principal->target pair)
|
||||
CALL apoc.create.vNode(['GlueDevEndpoint'], {
|
||||
name: 'New Dev Endpoint',
|
||||
description: 'Glue endpoint with target role attached',
|
||||
id: effective_principal.arn + '->' + target_role.arn
|
||||
})
|
||||
YIELD node AS glue_endpoint
|
||||
|
||||
CALL apoc.create.vRelationship(effective_principal, 'CREATES_ENDPOINT', {
|
||||
permissions: ['iam:PassRole', 'glue:CreateDevEndpoint'],
|
||||
technique: 'new-passrole'
|
||||
}, glue_endpoint)
|
||||
YIELD rel AS create_rel
|
||||
|
||||
CALL apoc.create.vRelationship(glue_endpoint, 'RUNS_AS', {}, target_role)
|
||||
YIELD rel AS runs_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/glue-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
// Re-match paths for visualization
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
MATCH path_target = (aws)--(target_role)
|
||||
|
||||
RETURN path_principal, path_target,
|
||||
glue_endpoint, escalation_outcome, create_rel, runs_rel, grants_rel
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-attach-role-policy-assume-role",
|
||||
name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole",
|
||||
description="Detect principals who can both attach policies to roles AND assume those roles. This two-step attack allows modifying a role's permissions then assuming it to gain elevated access. This is a principal-access escalation path (pathfinding.cloud: iam-014).",
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Create a virtual escalation outcome node (styled like a finding)
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {{
|
||||
id: 'effective-administrator',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
}})
|
||||
YIELD node AS admin_outcome
|
||||
|
||||
WITH admin_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)
|
||||
|
||||
// Find statements granting iam:AttachRolePolicy
|
||||
MATCH path_attach = (principal)--(attach_policy:AWSPolicy)--(stmt_attach:AWSPolicyStatement)
|
||||
WHERE stmt_attach.effect = 'Allow'
|
||||
AND any(action IN stmt_attach.action WHERE
|
||||
toLower(action) = 'iam:attachrolepolicy'
|
||||
OR toLower(action) = 'iam:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find statements granting sts:AssumeRole
|
||||
MATCH path_assume = (principal)--(assume_policy:AWSPolicy)--(stmt_assume:AWSPolicyStatement)
|
||||
WHERE stmt_assume.effect = 'Allow'
|
||||
AND any(action IN stmt_assume.action WHERE
|
||||
toLower(action) = 'sts:assumerole'
|
||||
OR toLower(action) = 'sts:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find target roles that the principal can both modify AND assume
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WHERE target_role.arn CONTAINS $provider_uid
|
||||
// Can attach policy to this role
|
||||
AND any(resource IN stmt_attach.resource WHERE
|
||||
resource = '*'
|
||||
OR target_role.arn CONTAINS resource
|
||||
OR resource CONTAINS target_role.name
|
||||
)
|
||||
// Can assume this role
|
||||
AND any(resource IN stmt_assume.resource WHERE
|
||||
resource = '*'
|
||||
OR target_role.arn CONTAINS resource
|
||||
OR resource CONTAINS target_role.name
|
||||
)
|
||||
|
||||
// Deduplicate before creating virtual relationships
|
||||
WITH DISTINCT admin_outcome, aws, principal, target_role
|
||||
|
||||
// Create virtual relationships showing the attack path
|
||||
CALL apoc.create.vRelationship(principal, 'CAN_MODIFY', {{
|
||||
via: 'iam:AttachRolePolicy'
|
||||
}}, target_role)
|
||||
YIELD rel AS modify_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'LEADS_TO', {{
|
||||
technique: 'iam:AttachRolePolicy + sts:AssumeRole',
|
||||
via: 'sts:AssumeRole',
|
||||
reference: 'https://pathfinding.cloud/paths/iam-014'
|
||||
}}, admin_outcome)
|
||||
YIELD rel AS escalation_rel
|
||||
|
||||
// Re-match paths for visualization
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
MATCH path_target = (aws)--(target_role)
|
||||
|
||||
UNWIND nodes(path_principal) + nodes(path_target) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL})
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_principal, path_target,
|
||||
admin_outcome, modify_rel, escalation_rel,
|
||||
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# TODO: Add ProwlerFinding nodes
|
||||
AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition(
|
||||
id="aws-bedrock-privesc-passrole-code-interpreter",
|
||||
name="Privilege Escalation: Bedrock Code Interpreter with PassRole",
|
||||
description="Detect principals that can escalate privileges by passing a role to a Bedrock AgentCore Code Interpreter. The attacker creates a code interpreter with an arbitrary role, then invokes it to execute code with those credentials.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-bedrock',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator (Bedrock)',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Principal can assume roles (up to 2 hops)
|
||||
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
|
||||
WITH escalation_outcome, aws, principal, path_principal, path_assume,
|
||||
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
|
||||
|
||||
// Find iam:PassRole permission
|
||||
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
|
||||
WHERE passrole_stmt.effect = 'Allow'
|
||||
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
|
||||
|
||||
// Find Bedrock AgentCore permissions
|
||||
MATCH (effective_principal)--(bedrock_policy:AWSPolicy)--(bedrock_stmt:AWSPolicyStatement)
|
||||
WHERE bedrock_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:createcodeinterpreter' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
|
||||
)
|
||||
AND (
|
||||
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:startsession' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
|
||||
)
|
||||
AND (
|
||||
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:invoke' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
|
||||
)
|
||||
|
||||
// Find target roles with elevated permissions that could be passed
|
||||
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
|
||||
WHERE target_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN target_stmt.action WHERE action = '*')
|
||||
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
// Deduplicate per (principal, target_role) pair
|
||||
WITH DISTINCT escalation_outcome, aws, principal, target_role
|
||||
|
||||
// Group by principal, collect target_roles
|
||||
WITH escalation_outcome, aws, principal,
|
||||
collect(DISTINCT target_role) AS target_roles,
|
||||
count(DISTINCT target_role) AS target_count
|
||||
|
||||
// Create single virtual Bedrock node per principal
|
||||
CALL apoc.create.vNode(['BedrockCodeInterpreter'], {
|
||||
name: 'New Code Interpreter',
|
||||
description: toString(target_count) + ' admin role(s) can be passed',
|
||||
id: principal.arn,
|
||||
target_role_count: target_count
|
||||
})
|
||||
YIELD node AS bedrock_agent
|
||||
|
||||
// Connect from principal (not effective_principal) to keep graph connected
|
||||
CALL apoc.create.vRelationship(principal, 'CREATES_INTERPRETER', {
|
||||
permissions: ['iam:PassRole', 'bedrock-agentcore:CreateCodeInterpreter', 'bedrock-agentcore:StartSession', 'bedrock-agentcore:Invoke'],
|
||||
technique: 'new-passrole'
|
||||
}, bedrock_agent)
|
||||
YIELD rel AS create_rel
|
||||
|
||||
// UNWIND target_roles to show which roles can be passed
|
||||
UNWIND target_roles AS target_role
|
||||
|
||||
CALL apoc.create.vRelationship(bedrock_agent, 'PASSES_ROLE', {}, target_role)
|
||||
YIELD rel AS pass_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/bedrock-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
// Re-match path for visualization
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
|
||||
RETURN path_principal,
|
||||
bedrock_agent, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
|
||||
# AWS Queries List
|
||||
# ----------------
|
||||
|
||||
AWS_QUERIES: list[AttackPathsQueryDefinition] = [
|
||||
AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS,
|
||||
AWS_RDS_INSTANCES,
|
||||
AWS_RDS_UNENCRYPTED_STORAGE,
|
||||
AWS_S3_ANONYMOUS_ACCESS_BUCKETS,
|
||||
AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS,
|
||||
AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY,
|
||||
AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS,
|
||||
AWS_EC2_INSTANCES_INTERNET_EXPOSED,
|
||||
AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING,
|
||||
AWS_CLASSIC_ELB_INTERNET_EXPOSED,
|
||||
AWS_ELBV2_INTERNET_EXPOSED,
|
||||
AWS_PUBLIC_IP_RESOURCE_LOOKUP,
|
||||
AWS_IAM_PRIVESC_PASSROLE_EC2,
|
||||
AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT,
|
||||
AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE,
|
||||
AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER,
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from api.attack_paths.queries.types import AttackPathsQueryDefinition
|
||||
from api.attack_paths.queries.aws import AWS_QUERIES
|
||||
|
||||
|
||||
# Query definitions organized by provider
|
||||
_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
|
||||
"aws": AWS_QUERIES,
|
||||
}
|
||||
|
||||
# Flat lookup by query ID for O(1) access
|
||||
_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = {
|
||||
definition.id: definition
|
||||
for definitions in _QUERY_DEFINITIONS.values()
|
||||
for definition in definitions
|
||||
}
|
||||
|
||||
|
||||
def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]:
|
||||
"""Get all attack path queries for a specific provider."""
|
||||
return _QUERY_DEFINITIONS.get(provider, [])
|
||||
|
||||
|
||||
def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None:
|
||||
"""Get a specific attack path query by its ID."""
|
||||
return _QUERIES_BY_ID.get(query_id)
|
||||
@@ -0,0 +1,29 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttackPathsQueryParameterDefinition:
|
||||
"""
|
||||
Metadata describing a parameter that must be provided to an Attack Paths query.
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
data_type: str = "string"
|
||||
cast: type = str
|
||||
description: str | None = None
|
||||
placeholder: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttackPathsQueryDefinition:
|
||||
"""
|
||||
Immutable representation of an Attack Path query.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
provider: str
|
||||
cypher: str
|
||||
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
|
||||
@@ -1,690 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# Dataclases for handling API's Attack Path query definitions and their parameters
|
||||
@dataclass
|
||||
class AttackPathsQueryParameterDefinition:
|
||||
"""
|
||||
Metadata describing a parameter that must be provided to an Attack Paths query.
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
data_type: str = "string"
|
||||
cast: type = str
|
||||
description: str | None = None
|
||||
placeholder: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttackPathsQueryDefinition:
|
||||
"""
|
||||
Immutable representation of an Attack Path query.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
provider: str
|
||||
cypher: str
|
||||
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
|
||||
|
||||
|
||||
# Accessor functions for API's Attack Paths query definitions
|
||||
def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]:
|
||||
return _QUERY_DEFINITIONS.get(provider, [])
|
||||
|
||||
|
||||
def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None:
|
||||
return _QUERIES_BY_ID.get(query_id)
|
||||
|
||||
|
||||
# API's Attack Paths query definitions
|
||||
_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
|
||||
"aws": [
|
||||
# Custom query for detecting internet-exposed EC2 instances with sensitive S3 access
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-internet-exposed-ec2-sensitive-s3-access",
|
||||
name="Identify internet-exposed EC2 instances with sensitive S3 access",
|
||||
description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
|
||||
YIELD node AS internet
|
||||
|
||||
MATCH path_s3 = (aws:AWSAccount {id: $provider_uid})--(s3:S3Bucket)--(t:AWSTag)
|
||||
WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value)
|
||||
|
||||
MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)
|
||||
WHERE ec2.exposed_internet = true
|
||||
AND ipi.toport = 22
|
||||
|
||||
MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name)
|
||||
AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*')
|
||||
|
||||
MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole)
|
||||
|
||||
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2)
|
||||
YIELD rel AS can_access
|
||||
|
||||
UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
|
||||
""",
|
||||
parameters=[
|
||||
AttackPathsQueryParameterDefinition(
|
||||
name="tag_key",
|
||||
label="Tag key",
|
||||
description="Tag key to filter the S3 bucket, e.g. DataClassification.",
|
||||
placeholder="DataClassification",
|
||||
),
|
||||
AttackPathsQueryParameterDefinition(
|
||||
name="tag_value",
|
||||
label="Tag value",
|
||||
description="Tag value to filter the S3 bucket, e.g. Sensitive.",
|
||||
placeholder="Sensitive",
|
||||
),
|
||||
],
|
||||
),
|
||||
# Regular Cartography Attack Paths queries
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-rds-instances",
|
||||
name="Identify provisioned RDS instances",
|
||||
description="List the selected AWS account alongside the RDS instances it owns.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-rds-unencrypted-storage",
|
||||
name="Identify RDS instances without storage encryption",
|
||||
description="Find RDS instances with storage encryption disabled within the selected account.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)
|
||||
WHERE rds.storage_encrypted = false
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-s3-anonymous-access-buckets",
|
||||
name="Identify S3 buckets with anonymous access",
|
||||
description="Find S3 buckets that allow anonymous access within the selected account.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
MATCH path = (aws:AWSAccount {id: $provider_uid})--(s3:S3Bucket)
|
||||
WHERE s3.anonymous_access = true
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-statements-allow-all-actions",
|
||||
name="Identify IAM statements that allow all actions",
|
||||
description="Find IAM policy statements that allow all actions via '*' within the selected account.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = 'Allow'
|
||||
AND any(x IN stmt.action WHERE x = '*')
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-statements-allow-delete-policy",
|
||||
name="Identify IAM statements that allow iam:DeletePolicy",
|
||||
description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = 'Allow'
|
||||
AND any(x IN stmt.action WHERE x = "iam:DeletePolicy")
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-statements-allow-create-actions",
|
||||
name="Identify IAM statements that allow create actions",
|
||||
description="Find IAM policy statements that allow actions containing 'create' within the selected account.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = "Allow"
|
||||
AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create")
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-ec2-instances-internet-exposed",
|
||||
name="Identify internet-exposed EC2 instances",
|
||||
description="Find EC2 instances flagged as exposed to the internet within the selected account.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
|
||||
YIELD node AS internet
|
||||
|
||||
MATCH path = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)
|
||||
WHERE ec2.exposed_internet = true
|
||||
|
||||
CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2)
|
||||
YIELD rel AS can_access
|
||||
|
||||
UNWIND nodes(path) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-security-groups-open-internet-facing",
|
||||
name="Identify internet-facing resources with open security groups",
|
||||
description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
|
||||
YIELD node AS internet
|
||||
|
||||
// Match 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,12 +1,13 @@
|
||||
import logging
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Iterable
|
||||
|
||||
from rest_framework.exceptions import APIException, ValidationError
|
||||
|
||||
from api.attack_paths import database as graph_database, AttackPathsQueryDefinition
|
||||
from api.models import AttackPathsScan
|
||||
from config.custom_logging import BackendLogger
|
||||
from tasks.jobs.attack_paths.config import INTERNAL_LABELS
|
||||
|
||||
logger = logging.getLogger(BackendLogger.API)
|
||||
|
||||
@@ -101,7 +102,7 @@ def _serialize_graph(graph):
|
||||
nodes.append(
|
||||
{
|
||||
"id": node.element_id,
|
||||
"labels": list(node.labels),
|
||||
"labels": _filter_labels(node.labels),
|
||||
"properties": _serialize_properties(node._properties),
|
||||
},
|
||||
)
|
||||
@@ -124,6 +125,10 @@ def _serialize_graph(graph):
|
||||
}
|
||||
|
||||
|
||||
def _filter_labels(labels: Iterable[str]) -> list[str]:
|
||||
return [label for label in labels if label not in INTERNAL_LABELS]
|
||||
|
||||
|
||||
def _serialize_properties(properties: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert Neo4j property values into JSON-serializable primitives."""
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django migration for Cloudflare provider support
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
import api.db_utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0074_findings_fail_new_index_parent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="provider",
|
||||
name="provider",
|
||||
field=api.db_utils.ProviderEnumField(
|
||||
choices=[
|
||||
("aws", "AWS"),
|
||||
("azure", "Azure"),
|
||||
("gcp", "GCP"),
|
||||
("kubernetes", "Kubernetes"),
|
||||
("m365", "M365"),
|
||||
("github", "GitHub"),
|
||||
("mongodbatlas", "MongoDB Atlas"),
|
||||
("iac", "IaC"),
|
||||
("oraclecloud", "Oracle Cloud Infrastructure"),
|
||||
("alibabacloud", "Alibaba Cloud"),
|
||||
("cloudflare", "Cloudflare"),
|
||||
],
|
||||
default="aws",
|
||||
),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'cloudflare';",
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
),
|
||||
]
|
||||
@@ -287,6 +287,7 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
IAC = "iac", _("IaC")
|
||||
ORACLECLOUD = "oraclecloud", _("Oracle Cloud Infrastructure")
|
||||
ALIBABACLOUD = "alibabacloud", _("Alibaba Cloud")
|
||||
CLOUDFLARE = "cloudflare", _("Cloudflare")
|
||||
|
||||
@staticmethod
|
||||
def validate_aws_uid(value):
|
||||
@@ -400,6 +401,15 @@ 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,6 +20,7 @@ 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
|
||||
@@ -118,6 +119,7 @@ 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):
|
||||
@@ -221,6 +223,10 @@ class TestGetProwlerProviderKwargs:
|
||||
Provider.ProviderChoices.MONGODBATLAS.value,
|
||||
{"atlas_organization_id": "provider_uid"},
|
||||
),
|
||||
(
|
||||
Provider.ProviderChoices.CLOUDFLARE.value,
|
||||
{"filter_accounts": ["provider_uid"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
|
||||
|
||||
@@ -1174,6 +1174,11 @@ class TestProviderViewSet:
|
||||
"uid": "1234567890123456",
|
||||
"alias": "Alibaba Cloud Account",
|
||||
},
|
||||
{
|
||||
"provider": "cloudflare",
|
||||
"uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
|
||||
"alias": "Cloudflare Account",
|
||||
},
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1553,6 +1558,46 @@ 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",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1726,21 +1771,21 @@ class TestProviderViewSet:
|
||||
(
|
||||
"uid.icontains",
|
||||
"1",
|
||||
8,
|
||||
9,
|
||||
),
|
||||
("alias", "aws_testing_1", 1),
|
||||
("alias.icontains", "aws", 2),
|
||||
("inserted_at", TODAY, 9),
|
||||
("inserted_at", TODAY, 10),
|
||||
(
|
||||
"inserted_at.gte",
|
||||
"2024-01-01",
|
||||
9,
|
||||
10,
|
||||
),
|
||||
("inserted_at.lte", "2024-01-01", 0),
|
||||
(
|
||||
"updated_at.gte",
|
||||
"2024-01-01",
|
||||
9,
|
||||
10,
|
||||
),
|
||||
("updated_at.lte", "2024-01-01", 0),
|
||||
]
|
||||
@@ -2330,6 +2375,23 @@ 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(
|
||||
@@ -10779,25 +10841,20 @@ class TestTenantFinishACSView:
|
||||
assert "sso_saml_failed=true" in response.url
|
||||
|
||||
def test_dispatch_skips_role_mapping_when_single_manage_account_user(
|
||||
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
admin_role_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Create a single role with manage_account=True for the user
|
||||
admin_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant=tenant,
|
||||
manage_account=True,
|
||||
manage_users=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
admin_role = admin_role_fixture
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
@@ -10868,35 +10925,26 @@ class TestTenantFinishACSView:
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
|
||||
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_existing_role(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
admin_role_fixture,
|
||||
roles_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
|
||||
"""Test that role mapping is skipped when it would remove the last MANAGE_ACCOUNT user"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Create a second user with manage_account=True
|
||||
second_admin = User.objects.using(MainRouter.admin_db).create(
|
||||
email="admin2@prowler.com", name="Second Admin"
|
||||
)
|
||||
admin_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant=tenant,
|
||||
manage_account=True,
|
||||
manage_users=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
admin_role = admin_role_fixture
|
||||
viewer_role = roles_fixture[3]
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=second_admin, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
@@ -10905,7 +10953,7 @@ class TestTenantFinishACSView:
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": ["viewer"], # This SHOULD be applied
|
||||
"userType": [viewer_role.name],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -10943,10 +10991,91 @@ class TestTenantFinishACSView:
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# Verify the viewer role was created and assigned (role mapping was applied)
|
||||
viewer_role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name="viewer", tenant=tenant
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=admin_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
assert not (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
admin_role_fixture,
|
||||
roles_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Create a second user with manage_account=True
|
||||
second_admin = User.objects.using(MainRouter.admin_db).create(
|
||||
email="admin2@prowler.com", name="Second Admin"
|
||||
)
|
||||
admin_role = admin_role_fixture
|
||||
viewer_role = roles_fixture[3]
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=second_admin, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": [viewer_role.name], # This SHOULD be applied
|
||||
},
|
||||
)
|
||||
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = user
|
||||
request.session = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
|
||||
mock_saml_config_get.return_value = MagicMock()
|
||||
mock_user_get.return_value = user
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# Verify the viewer role was assigned (role mapping was applied)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
|
||||
@@ -10960,6 +11089,86 @@ class TestTenantFinishACSView:
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_dispatch_applies_role_mapping_for_non_admin_user_with_single_admin(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
admin_role_fixture,
|
||||
roles_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that role mapping is applied for a non-admin user when a single admin exists"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
admin_user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
non_admin_user = User.objects.using(MainRouter.admin_db).create(
|
||||
email="viewer@prowler.com", name="Viewer"
|
||||
)
|
||||
|
||||
admin_role = admin_role_fixture
|
||||
viewer_role = roles_fixture[3]
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=admin_user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=non_admin_user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["Jane"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": [viewer_role.name],
|
||||
},
|
||||
)
|
||||
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = non_admin_user
|
||||
request.session = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
|
||||
mock_saml_config_get.return_value = MagicMock()
|
||||
mock_user_get.return_value = non_admin_user
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=non_admin_user, role=viewer_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=admin_user, role=admin_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLighthouseConfigViewSet:
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -91,7 +92,7 @@ def return_prowler_provider(
|
||||
provider (Provider): The provider object containing the provider type and associated secrets.
|
||||
|
||||
Returns:
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class.
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: The corresponding provider class.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider type specified in `provider.provider` is not supported.
|
||||
@@ -145,6 +146,12 @@ 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
|
||||
@@ -196,6 +203,11 @@ 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", {})
|
||||
@@ -213,6 +225,7 @@ def initialize_prowler_provider(
|
||||
AlibabacloudProvider
|
||||
| AwsProvider
|
||||
| AzureProvider
|
||||
| CloudflareProvider
|
||||
| GcpProvider
|
||||
| GithubProvider
|
||||
| IacProvider
|
||||
@@ -228,7 +241,7 @@ def initialize_prowler_provider(
|
||||
mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration.
|
||||
|
||||
Returns:
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OraclecloudProvider: An instance of the corresponding provider class
|
||||
initialized with the provider's secrets.
|
||||
"""
|
||||
prowler_provider = return_prowler_provider(provider)
|
||||
|
||||
@@ -346,6 +346,33 @@ 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,6 +1503,18 @@ 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}"}
|
||||
@@ -1654,6 +1666,21 @@ class OracleCloudProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class CloudflareTokenProviderSecret(serializers.Serializer):
|
||||
api_token = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class CloudflareApiKeyProviderSecret(serializers.Serializer):
|
||||
api_key = serializers.CharField()
|
||||
api_email = serializers.EmailField()
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class AlibabaCloudProviderSecret(serializers.Serializer):
|
||||
access_key_id = serializers.CharField()
|
||||
access_key_secret = serializers.CharField()
|
||||
|
||||
@@ -392,7 +392,7 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
spectacular_settings.TITLE = "Prowler API"
|
||||
spectacular_settings.VERSION = "1.19.0"
|
||||
spectacular_settings.VERSION = "1.19.2"
|
||||
spectacular_settings.DESCRIPTION = (
|
||||
"Prowler API specification.\n\nThis file is auto-generated."
|
||||
)
|
||||
@@ -763,27 +763,40 @@ class TenantFinishACSView(FinishACSView):
|
||||
.tenant
|
||||
)
|
||||
|
||||
# Check if tenant has only one user with MANAGE_ACCOUNT role
|
||||
users_with_manage_account = (
|
||||
role_name = (
|
||||
extra.get("userType", ["no_permissions"])[0].strip()
|
||||
if extra.get("userType")
|
||||
else "no_permissions"
|
||||
)
|
||||
role = (
|
||||
Role.objects.using(MainRouter.admin_db)
|
||||
.filter(name=role_name, tenant=tenant)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Only skip mapping if it would remove the last MANAGE_ACCOUNT user
|
||||
remaining_manage_account_users = (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(role__manage_account=True, tenant_id=tenant.id)
|
||||
.exclude(user_id=user_id)
|
||||
.values("user")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
user_has_manage_account = (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id)
|
||||
.exists()
|
||||
)
|
||||
role_manage_account = role.manage_account if role else False
|
||||
would_remove_last_manage_account = (
|
||||
user_has_manage_account
|
||||
and remaining_manage_account_users == 0
|
||||
and not role_manage_account
|
||||
)
|
||||
|
||||
# Only apply role mapping from userType if tenant does NOT have exactly one user with MANAGE_ACCOUNT
|
||||
if users_with_manage_account != 1:
|
||||
role_name = (
|
||||
extra.get("userType", ["no_permissions"])[0].strip()
|
||||
if extra.get("userType")
|
||||
else "no_permissions"
|
||||
)
|
||||
try:
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=role_name, tenant=tenant
|
||||
)
|
||||
except Role.DoesNotExist:
|
||||
if not would_remove_last_manage_account:
|
||||
if role is None:
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant=tenant,
|
||||
@@ -2287,7 +2300,7 @@ class TaskViewSet(BaseRLSViewSet):
|
||||
),
|
||||
attack_paths_queries=extend_schema(
|
||||
tags=["Attack Paths"],
|
||||
summary="List attack paths queries",
|
||||
summary="List Attack Paths queries",
|
||||
description="Retrieve the catalog of Attack Paths queries available for this Attack Paths scan.",
|
||||
responses={
|
||||
200: OpenApiResponse(AttackPathsQuerySerializer(many=True)),
|
||||
@@ -2307,7 +2320,7 @@ class TaskViewSet(BaseRLSViewSet):
|
||||
description="Bad request (e.g., Unknown Attack Paths query for the selected provider)"
|
||||
),
|
||||
404: OpenApiResponse(
|
||||
description="No attack paths found for the given query and parameters"
|
||||
description="No Attack Paths found for the given query and parameters"
|
||||
),
|
||||
500: OpenApiResponse(
|
||||
description="Attack Paths query execution failed due to a database error"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from django.conf import settings
|
||||
from django.db import connection as django_connection
|
||||
@@ -14,6 +12,11 @@ from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from tasks.jobs.backfill import (
|
||||
backfill_resource_scan_summaries,
|
||||
backfill_scan_category_summaries,
|
||||
backfill_scan_resource_group_summaries,
|
||||
)
|
||||
|
||||
from api.attack_paths import (
|
||||
AttackPathsQueryDefinition,
|
||||
@@ -59,11 +62,6 @@ from api.rls import Tenant
|
||||
from api.v1.serializers import TokenSerializer
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.backfill import (
|
||||
backfill_resource_scan_summaries,
|
||||
backfill_scan_category_summaries,
|
||||
backfill_scan_resource_group_summaries,
|
||||
)
|
||||
|
||||
TODAY = str(datetime.today().date())
|
||||
API_JSON_CONTENT_TYPE = "application/vnd.api+json"
|
||||
@@ -533,6 +531,12 @@ 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,
|
||||
@@ -544,6 +548,7 @@ 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 version 0.122.0, specifically on `cartography.intel.aws.__init__.py`.
|
||||
Code based on Cartography, specifically on `cartography.intel.aws.__init__.py`.
|
||||
|
||||
For the scan progress updates:
|
||||
- The caller of this function (`tasks.jobs.attack_paths.scan.run`) has set it to 2.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from config.env import env
|
||||
|
||||
from tasks.jobs.attack_paths import aws
|
||||
|
||||
|
||||
# Batch size for Neo4j operations
|
||||
BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
|
||||
|
||||
# Neo4j internal labels (Prowler-specific, not provider-specific)
|
||||
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources.
|
||||
# - `ProviderResource`: Added to ALL synced nodes for provider isolation and drop/query ops.
|
||||
PROWLER_FINDING_LABEL = "ProwlerFinding"
|
||||
PROVIDER_RESOURCE_LABEL = "ProviderResource"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConfig:
|
||||
"""Configuration for a cloud provider's Attack Paths integration."""
|
||||
|
||||
name: str
|
||||
root_node_label: str # e.g., "AWSAccount"
|
||||
uid_field: str # e.g., "arn"
|
||||
# Label for resources connected to the account node, enabling indexed finding lookups.
|
||||
resource_label: str # e.g., "AWSResource"
|
||||
ingestion_function: Callable
|
||||
|
||||
|
||||
# Provider Configurations
|
||||
# -----------------------
|
||||
|
||||
AWS_CONFIG = ProviderConfig(
|
||||
name="aws",
|
||||
root_node_label="AWSAccount",
|
||||
uid_field="arn",
|
||||
resource_label="AWSResource",
|
||||
ingestion_function=aws.start_aws_ingestion,
|
||||
)
|
||||
|
||||
PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
|
||||
"aws": AWS_CONFIG,
|
||||
}
|
||||
|
||||
# Labels added by Prowler that should be filtered from API responses
|
||||
# Derived from provider configs + common internal labels
|
||||
INTERNAL_LABELS: list[str] = [
|
||||
"Tenant",
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
# Add all provider-specific resource labels
|
||||
*[config.resource_label for config in PROVIDER_CONFIGS.values()],
|
||||
]
|
||||
|
||||
|
||||
# Provider Config Accessors
|
||||
# -------------------------
|
||||
|
||||
|
||||
def is_provider_available(provider_type: str) -> bool:
|
||||
"""Check if a provider type is available for Attack Paths scans."""
|
||||
return provider_type in PROVIDER_CONFIGS
|
||||
|
||||
|
||||
def get_cartography_ingestion_function(provider_type: str) -> Callable | None:
|
||||
"""Get the Cartography ingestion function for a provider type."""
|
||||
config = PROVIDER_CONFIGS.get(provider_type)
|
||||
return config.ingestion_function if config else None
|
||||
|
||||
|
||||
def get_root_node_label(provider_type: str) -> str:
|
||||
"""Get the root node label for a provider type (e.g., AWSAccount)."""
|
||||
config = PROVIDER_CONFIGS.get(provider_type)
|
||||
return config.root_node_label if config else "UnknownProviderAccount"
|
||||
|
||||
|
||||
def get_node_uid_field(provider_type: str) -> str:
|
||||
"""Get the UID field for a provider type (e.g., arn for AWS)."""
|
||||
config = PROVIDER_CONFIGS.get(provider_type)
|
||||
return config.uid_field if config else "UnknownProviderUID"
|
||||
|
||||
|
||||
def get_provider_resource_label(provider_type: str) -> str:
|
||||
"""Get the resource label for a provider type (e.g., `AWSResource`)."""
|
||||
config = PROVIDER_CONFIGS.get(provider_type)
|
||||
return config.resource_label if config else "UnknownProviderResource"
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Q
|
||||
from cartography.config import Config as CartographyConfig
|
||||
|
||||
from api.db_utils import rls_transaction
|
||||
@@ -10,7 +9,7 @@ from api.models import (
|
||||
Provider as ProwlerAPIProvider,
|
||||
StateChoices,
|
||||
)
|
||||
from tasks.jobs.attack_paths.providers import is_provider_available
|
||||
from tasks.jobs.attack_paths.config import is_provider_available
|
||||
|
||||
|
||||
def can_provider_run_attack_paths_scan(tenant_id: str, provider_id: int) -> bool:
|
||||
@@ -145,24 +144,3 @@ def update_old_attack_paths_scan(
|
||||
with rls_transaction(old_attack_paths_scan.tenant_id):
|
||||
old_attack_paths_scan.is_graph_database_deleted = True
|
||||
old_attack_paths_scan.save(update_fields=["is_graph_database_deleted"])
|
||||
|
||||
|
||||
def get_provider_graph_database_names(tenant_id: str, provider_id: str) -> list[str]:
|
||||
"""
|
||||
Return existing graph database names for a tenant/provider.
|
||||
|
||||
Note: For accesing the `AttackPathsScan` we need to use `all_objects` manager because the provider is soft-deleted.
|
||||
"""
|
||||
with rls_transaction(tenant_id):
|
||||
graph_databases_names_qs = (
|
||||
ProwlerAPIAttackPathsScan.all_objects.filter(
|
||||
~Q(graph_database=""),
|
||||
graph_database__isnull=False,
|
||||
provider_id=provider_id,
|
||||
is_graph_database_deleted=False,
|
||||
)
|
||||
.values_list("graph_database", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return list(graph_databases_names_qs)
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Prowler findings ingestion into Neo4j graph.
|
||||
|
||||
This module handles:
|
||||
- Adding resource labels to Cartography nodes for efficient lookups
|
||||
- Loading Prowler findings into the graph
|
||||
- Linking findings to resources
|
||||
- Cleaning up stale findings
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from typing import Any, Generator
|
||||
from uuid import UUID
|
||||
|
||||
import neo4j
|
||||
|
||||
from cartography.config import Config as CartographyConfig
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding as FindingModel
|
||||
from api.models import Provider, ResourceFindingMapping
|
||||
from prowler.config import config as ProwlerConfig
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
get_node_uid_field,
|
||||
get_provider_resource_label,
|
||||
get_root_node_label,
|
||||
)
|
||||
from tasks.jobs.attack_paths.indexes import IndexType, create_indexes
|
||||
from tasks.jobs.attack_paths.queries import (
|
||||
ADD_RESOURCE_LABEL_TEMPLATE,
|
||||
CLEANUP_FINDINGS_TEMPLATE,
|
||||
INSERT_FINDING_TEMPLATE,
|
||||
render_cypher_template,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
# Type Definitions
|
||||
# -----------------
|
||||
|
||||
# Maps dataclass field names to Django ORM query field names
|
||||
_DB_FIELD_MAP: dict[str, str] = {
|
||||
"check_title": "check_metadata__checktitle",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Finding:
|
||||
"""
|
||||
Finding data for Neo4j ingestion.
|
||||
|
||||
Can be created from a Django .values() query result using from_db_record().
|
||||
"""
|
||||
|
||||
id: str
|
||||
uid: str
|
||||
inserted_at: str
|
||||
updated_at: str
|
||||
first_seen_at: str
|
||||
scan_id: str
|
||||
delta: str
|
||||
status: str
|
||||
status_extended: str
|
||||
severity: str
|
||||
check_id: str
|
||||
check_title: str
|
||||
muted: bool
|
||||
muted_reason: str | None
|
||||
resource_uid: str | None = None
|
||||
|
||||
@classmethod
|
||||
def get_db_query_fields(cls) -> tuple[str, ...]:
|
||||
"""Get field names for Django .values() query."""
|
||||
return tuple(
|
||||
_DB_FIELD_MAP.get(f.name, f.name)
|
||||
for f in fields(cls)
|
||||
if f.name != "resource_uid"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_record(cls, record: dict[str, Any], resource_uid: str) -> "Finding":
|
||||
"""Create a Finding from a Django .values() query result."""
|
||||
return cls(
|
||||
id=str(record["id"]),
|
||||
uid=record["uid"],
|
||||
inserted_at=record["inserted_at"],
|
||||
updated_at=record["updated_at"],
|
||||
first_seen_at=record["first_seen_at"],
|
||||
scan_id=str(record["scan_id"]),
|
||||
delta=record["delta"],
|
||||
status=record["status"],
|
||||
status_extended=record["status_extended"],
|
||||
severity=record["severity"],
|
||||
check_id=str(record["check_id"]),
|
||||
check_title=record["check_metadata__checktitle"],
|
||||
muted=record["muted"],
|
||||
muted_reason=record["muted_reason"],
|
||||
resource_uid=resource_uid,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dict for Neo4j ingestion."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# Public API
|
||||
# ----------
|
||||
|
||||
|
||||
def create_findings_indexes(neo4j_session: neo4j.Session) -> None:
|
||||
"""Create indexes for Prowler findings and resource lookups."""
|
||||
create_indexes(neo4j_session, IndexType.FINDINGS)
|
||||
|
||||
|
||||
def analysis(
|
||||
neo4j_session: neo4j.Session,
|
||||
prowler_api_provider: Provider,
|
||||
scan_id: str,
|
||||
config: CartographyConfig,
|
||||
) -> None:
|
||||
"""
|
||||
Main entry point for Prowler findings analysis.
|
||||
|
||||
Adds resource labels, loads findings, and cleans up stale data.
|
||||
"""
|
||||
add_resource_label(
|
||||
neo4j_session, prowler_api_provider.provider, str(prowler_api_provider.uid)
|
||||
)
|
||||
findings_data = stream_findings_with_resources(prowler_api_provider, scan_id)
|
||||
load_findings(neo4j_session, findings_data, prowler_api_provider, config)
|
||||
cleanup_findings(neo4j_session, prowler_api_provider, config)
|
||||
|
||||
|
||||
def add_resource_label(
|
||||
neo4j_session: neo4j.Session, provider_type: str, provider_uid: str
|
||||
) -> int:
|
||||
"""
|
||||
Add a common resource label to all nodes connected to the provider account.
|
||||
|
||||
This enables index usage for resource lookups in the findings query,
|
||||
since Cartography nodes don't have a common parent label.
|
||||
|
||||
Returns the total number of nodes labeled.
|
||||
"""
|
||||
query = render_cypher_template(
|
||||
ADD_RESOURCE_LABEL_TEMPLATE,
|
||||
{
|
||||
"__ROOT_LABEL__": get_root_node_label(provider_type),
|
||||
"__RESOURCE_LABEL__": get_provider_resource_label(provider_type),
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Adding {get_provider_resource_label(provider_type)} label to all resources for {provider_uid}"
|
||||
)
|
||||
|
||||
total_labeled = 0
|
||||
labeled_count = 1
|
||||
|
||||
while labeled_count > 0:
|
||||
result = neo4j_session.run(
|
||||
query,
|
||||
{"provider_uid": provider_uid, "batch_size": BATCH_SIZE},
|
||||
)
|
||||
labeled_count = result.single().get("labeled_count", 0)
|
||||
total_labeled += labeled_count
|
||||
|
||||
if labeled_count > 0:
|
||||
logger.info(
|
||||
f"Labeled {total_labeled} nodes with {get_provider_resource_label(provider_type)}"
|
||||
)
|
||||
|
||||
return total_labeled
|
||||
|
||||
|
||||
def load_findings(
|
||||
neo4j_session: neo4j.Session,
|
||||
findings_batches: Generator[list[Finding], None, None],
|
||||
prowler_api_provider: Provider,
|
||||
config: CartographyConfig,
|
||||
) -> None:
|
||||
"""Load Prowler findings into the graph, linking them to resources."""
|
||||
query = render_cypher_template(
|
||||
INSERT_FINDING_TEMPLATE,
|
||||
{
|
||||
"__ROOT_NODE_LABEL__": get_root_node_label(prowler_api_provider.provider),
|
||||
"__NODE_UID_FIELD__": get_node_uid_field(prowler_api_provider.provider),
|
||||
"__RESOURCE_LABEL__": get_provider_resource_label(
|
||||
prowler_api_provider.provider
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
parameters = {
|
||||
"provider_uid": str(prowler_api_provider.uid),
|
||||
"last_updated": config.update_tag,
|
||||
"prowler_version": ProwlerConfig.prowler_version,
|
||||
}
|
||||
|
||||
batch_num = 0
|
||||
total_records = 0
|
||||
for batch in findings_batches:
|
||||
batch_num += 1
|
||||
batch_size = len(batch)
|
||||
total_records += batch_size
|
||||
|
||||
parameters["findings_data"] = [f.to_dict() for f in batch]
|
||||
|
||||
logger.info(f"Loading findings batch {batch_num} ({batch_size} records)")
|
||||
neo4j_session.run(query, parameters)
|
||||
|
||||
logger.info(f"Finished loading {total_records} records in {batch_num} batches")
|
||||
|
||||
|
||||
def cleanup_findings(
|
||||
neo4j_session: neo4j.Session,
|
||||
prowler_api_provider: Provider,
|
||||
config: CartographyConfig,
|
||||
) -> None:
|
||||
"""Remove stale findings (classic Cartography behaviour)."""
|
||||
parameters = {
|
||||
"provider_uid": str(prowler_api_provider.uid),
|
||||
"last_updated": config.update_tag,
|
||||
"batch_size": BATCH_SIZE,
|
||||
}
|
||||
|
||||
batch = 1
|
||||
deleted_count = 1
|
||||
while deleted_count > 0:
|
||||
logger.info(f"Cleaning findings batch {batch}")
|
||||
|
||||
result = neo4j_session.run(CLEANUP_FINDINGS_TEMPLATE, parameters)
|
||||
|
||||
deleted_count = result.single().get("deleted_findings_count", 0)
|
||||
batch += 1
|
||||
|
||||
|
||||
# Findings Streaming (Generator-based)
|
||||
# -------------------------------------
|
||||
|
||||
|
||||
def stream_findings_with_resources(
|
||||
prowler_api_provider: Provider,
|
||||
scan_id: str,
|
||||
) -> Generator[list[Finding], None, None]:
|
||||
"""
|
||||
Stream findings with their associated resources in batches.
|
||||
|
||||
Uses keyset pagination for efficient traversal of large datasets.
|
||||
Memory efficient: yields one batch at a time, never holds all findings in memory.
|
||||
"""
|
||||
logger.info(
|
||||
f"Starting findings stream for scan {scan_id} "
|
||||
f"(tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}"
|
||||
)
|
||||
|
||||
tenant_id = prowler_api_provider.tenant_id
|
||||
for batch in _paginate_findings(tenant_id, scan_id):
|
||||
enriched = _enrich_batch_with_resources(batch, tenant_id)
|
||||
if enriched:
|
||||
yield enriched
|
||||
|
||||
logger.info(f"Finished streaming findings for scan {scan_id}")
|
||||
|
||||
|
||||
def _paginate_findings(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
) -> Generator[list[dict[str, Any]], None, None]:
|
||||
"""
|
||||
Paginate through findings using keyset pagination.
|
||||
|
||||
Each iteration fetches one batch within its own RLS transaction,
|
||||
preventing long-held database connections.
|
||||
"""
|
||||
last_id = None
|
||||
iteration = 0
|
||||
|
||||
while True:
|
||||
iteration += 1
|
||||
batch = _fetch_findings_batch(tenant_id, scan_id, last_id)
|
||||
|
||||
logger.info(f"Iteration #{iteration}: fetched {len(batch)} findings")
|
||||
|
||||
if not batch:
|
||||
break
|
||||
|
||||
last_id = batch[-1]["id"]
|
||||
yield batch
|
||||
|
||||
|
||||
def _fetch_findings_batch(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
after_id: UUID | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch a single batch of findings from the database.
|
||||
|
||||
Uses read replica and RLS-scoped transaction.
|
||||
"""
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
# Use all_objects to avoid the ActiveProviderManager's implicit JOIN
|
||||
# through Scan -> Provider (to check is_deleted=False).
|
||||
# The provider is already validated as active in this context.
|
||||
qs = FindingModel.all_objects.filter(scan_id=scan_id).order_by("id")
|
||||
|
||||
if after_id is not None:
|
||||
qs = qs.filter(id__gt=after_id)
|
||||
|
||||
return list(qs.values(*Finding.get_db_query_fields())[:BATCH_SIZE])
|
||||
|
||||
|
||||
# Batch Enrichment
|
||||
# -----------------
|
||||
|
||||
|
||||
def _enrich_batch_with_resources(
|
||||
findings_batch: list[dict[str, Any]],
|
||||
tenant_id: str,
|
||||
) -> list[Finding]:
|
||||
"""
|
||||
Enrich findings with their resource UIDs.
|
||||
|
||||
One finding with N resources becomes N output records.
|
||||
Findings without resources are skipped.
|
||||
"""
|
||||
finding_ids = [f["id"] for f in findings_batch]
|
||||
resource_map = _build_finding_resource_map(finding_ids, tenant_id)
|
||||
|
||||
return [
|
||||
Finding.from_db_record(finding, resource_uid)
|
||||
for finding in findings_batch
|
||||
for resource_uid in resource_map.get(finding["id"], [])
|
||||
]
|
||||
|
||||
|
||||
def _build_finding_resource_map(
|
||||
finding_ids: list[UUID], tenant_id: str
|
||||
) -> dict[UUID, list[str]]:
|
||||
"""Build mapping from finding_id to list of resource UIDs."""
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
resource_mappings = ResourceFindingMapping.objects.filter(
|
||||
finding_id__in=finding_ids
|
||||
).values_list("finding_id", "resource__uid")
|
||||
|
||||
result = defaultdict(list)
|
||||
for finding_id, resource_uid in resource_mappings:
|
||||
result[finding_id].append(resource_uid)
|
||||
return result
|
||||
@@ -0,0 +1,64 @@
|
||||
from enum import Enum
|
||||
|
||||
import neo4j
|
||||
|
||||
from cartography.client.core.tx import run_write_query
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
PROWLER_FINDING_LABEL,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
class IndexType(Enum):
|
||||
"""Types of indexes that can be created."""
|
||||
|
||||
FINDINGS = "findings"
|
||||
SYNC = "sync"
|
||||
|
||||
|
||||
# Indexes for Prowler findings and resource lookups
|
||||
FINDINGS_INDEX_STATEMENTS = [
|
||||
# Resources indexes for quick Prowler Finding lookups
|
||||
"CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:AWSResource) ON (n.arn);",
|
||||
"CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:AWSResource) ON (n.id);",
|
||||
# Prowler Finding indexes
|
||||
f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);",
|
||||
f"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.provider_uid);",
|
||||
f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);",
|
||||
f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);",
|
||||
]
|
||||
|
||||
# Indexes for provider resource sync operations
|
||||
SYNC_INDEX_STATEMENTS = [
|
||||
f"CREATE INDEX provider_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.provider_element_id);",
|
||||
f"CREATE INDEX provider_resource_provider_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.provider_id);",
|
||||
]
|
||||
|
||||
|
||||
def create_indexes(neo4j_session: neo4j.Session, index_type: IndexType) -> None:
|
||||
"""
|
||||
Create indexes for the specified type.
|
||||
|
||||
Args:
|
||||
`neo4j_session`: The Neo4j session to use
|
||||
`index_type`: The type of indexes to create (FINDINGS or SYNC)
|
||||
"""
|
||||
if index_type == IndexType.FINDINGS:
|
||||
logger.info("Creating indexes for Prowler Findings node types")
|
||||
for statement in FINDINGS_INDEX_STATEMENTS:
|
||||
run_write_query(neo4j_session, statement)
|
||||
|
||||
elif index_type == IndexType.SYNC:
|
||||
logger.info("Ensuring ProviderResource indexes exist")
|
||||
for statement in SYNC_INDEX_STATEMENTS:
|
||||
neo4j_session.run(statement)
|
||||
|
||||
|
||||
def create_all_indexes(neo4j_session: neo4j.Session) -> None:
|
||||
"""Create all indexes (both findings and sync)."""
|
||||
create_indexes(neo4j_session, IndexType.FINDINGS)
|
||||
create_indexes(neo4j_session, IndexType.SYNC)
|
||||
@@ -1,23 +0,0 @@
|
||||
AVAILABLE_PROVIDERS: list[str] = [
|
||||
"aws",
|
||||
]
|
||||
|
||||
ROOT_NODE_LABELS: dict[str, str] = {
|
||||
"aws": "AWSAccount",
|
||||
}
|
||||
|
||||
NODE_UID_FIELDS: dict[str, str] = {
|
||||
"aws": "arn",
|
||||
}
|
||||
|
||||
|
||||
def is_provider_available(provider_type: str) -> bool:
|
||||
return provider_type in AVAILABLE_PROVIDERS
|
||||
|
||||
|
||||
def get_root_node_label(provider_type: str) -> str:
|
||||
return ROOT_NODE_LABELS.get(provider_type, "UnknownProviderAccount")
|
||||
|
||||
|
||||
def get_node_uid_field(provider_type: str) -> str:
|
||||
return NODE_UID_FIELDS.get(provider_type, "UnknownProviderUID")
|
||||
@@ -1,290 +0,0 @@
|
||||
from collections import defaultdict
|
||||
from typing import Generator
|
||||
|
||||
import neo4j
|
||||
from cartography.client.core.tx import run_write_query
|
||||
from cartography.config import Config as CartographyConfig
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.env import env
|
||||
from tasks.jobs.attack_paths.providers import get_node_uid_field, get_root_node_label
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, Provider, ResourceFindingMapping
|
||||
from prowler.config import config as ProwlerConfig
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000)
|
||||
|
||||
INDEX_STATEMENTS = [
|
||||
"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.id);",
|
||||
"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.provider_uid);",
|
||||
"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.lastupdated);",
|
||||
"CREATE INDEX prowler_finding_check_id IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.status);",
|
||||
]
|
||||
|
||||
INSERT_STATEMENT_TEMPLATE = """
|
||||
MATCH (account:__ROOT_NODE_LABEL__ {id: $provider_uid})
|
||||
UNWIND $findings_data AS finding_data
|
||||
|
||||
OPTIONAL MATCH (account)-->(resource_by_uid)
|
||||
WHERE resource_by_uid.__NODE_UID_FIELD__ = finding_data.resource_uid
|
||||
WITH account, finding_data, resource_by_uid
|
||||
|
||||
OPTIONAL MATCH (account)-->(resource_by_id)
|
||||
WHERE resource_by_uid IS NULL
|
||||
AND resource_by_id.id = finding_data.resource_uid
|
||||
WITH account, finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource
|
||||
WHERE resource IS NOT NULL
|
||||
|
||||
MERGE (finding:ProwlerFinding {id: finding_data.id})
|
||||
ON CREATE SET
|
||||
finding.id = finding_data.id,
|
||||
finding.uid = finding_data.uid,
|
||||
finding.inserted_at = finding_data.inserted_at,
|
||||
finding.updated_at = finding_data.updated_at,
|
||||
finding.first_seen_at = finding_data.first_seen_at,
|
||||
finding.scan_id = finding_data.scan_id,
|
||||
finding.delta = finding_data.delta,
|
||||
finding.status = finding_data.status,
|
||||
finding.status_extended = finding_data.status_extended,
|
||||
finding.severity = finding_data.severity,
|
||||
finding.check_id = finding_data.check_id,
|
||||
finding.check_title = finding_data.check_title,
|
||||
finding.muted = finding_data.muted,
|
||||
finding.muted_reason = finding_data.muted_reason,
|
||||
finding.provider_uid = $provider_uid,
|
||||
finding.firstseen = timestamp(),
|
||||
finding.lastupdated = $last_updated,
|
||||
finding._module_name = 'cartography:prowler',
|
||||
finding._module_version = $prowler_version
|
||||
ON MATCH SET
|
||||
finding.status = finding_data.status,
|
||||
finding.status_extended = finding_data.status_extended,
|
||||
finding.lastupdated = $last_updated
|
||||
|
||||
MERGE (resource)-[rel:HAS_FINDING]->(finding)
|
||||
ON CREATE SET
|
||||
rel.provider_uid = $provider_uid,
|
||||
rel.firstseen = timestamp(),
|
||||
rel.lastupdated = $last_updated,
|
||||
rel._module_name = 'cartography:prowler',
|
||||
rel._module_version = $prowler_version
|
||||
ON MATCH SET
|
||||
rel.lastupdated = $last_updated
|
||||
"""
|
||||
|
||||
CLEANUP_STATEMENT = """
|
||||
MATCH (finding:ProwlerFinding {provider_uid: $provider_uid})
|
||||
WHERE finding.lastupdated < $last_updated
|
||||
|
||||
WITH finding LIMIT $batch_size
|
||||
|
||||
DETACH DELETE finding
|
||||
|
||||
RETURN COUNT(finding) AS deleted_findings_count
|
||||
"""
|
||||
|
||||
|
||||
def create_indexes(neo4j_session: neo4j.Session) -> None:
|
||||
"""
|
||||
Code based on Cartography version 0.122.0, specifically on `cartography.intel.create_indexes.run`.
|
||||
"""
|
||||
|
||||
logger.info("Creating indexes for Prowler Findings node types")
|
||||
for statement in INDEX_STATEMENTS:
|
||||
run_write_query(neo4j_session, statement)
|
||||
|
||||
|
||||
def analysis(
|
||||
neo4j_session: neo4j.Session,
|
||||
prowler_api_provider: Provider,
|
||||
scan_id: str,
|
||||
config: CartographyConfig,
|
||||
) -> None:
|
||||
findings_data = get_provider_last_scan_findings(prowler_api_provider, scan_id)
|
||||
load_findings(neo4j_session, findings_data, prowler_api_provider, config)
|
||||
cleanup_findings(neo4j_session, prowler_api_provider, config)
|
||||
|
||||
|
||||
def get_provider_last_scan_findings(
|
||||
prowler_api_provider: Provider,
|
||||
scan_id: str,
|
||||
) -> Generator[list[dict[str, str]], None, None]:
|
||||
"""
|
||||
Generator that yields batches of finding-resource pairs.
|
||||
|
||||
Two-step query approach per batch:
|
||||
1. Paginate findings for scan (single table, indexed by scan_id)
|
||||
2. Batch-fetch resource UIDs via mapping table (single join)
|
||||
3. Merge and yield flat structure for Neo4j
|
||||
|
||||
Memory efficient: never holds more than BATCH_SIZE findings in memory.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
f"Starting findings fetch for scan {scan_id} (tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}"
|
||||
)
|
||||
|
||||
iteration = 0
|
||||
last_id = None
|
||||
|
||||
while True:
|
||||
iteration += 1
|
||||
|
||||
with rls_transaction(prowler_api_provider.tenant_id, using=READ_REPLICA_ALIAS):
|
||||
# Use all_objects to avoid the ActiveProviderManager's implicit JOIN
|
||||
# through Scan -> Provider (to check is_deleted=False).
|
||||
# The provider is already validated as active in this context.
|
||||
qs = Finding.all_objects.filter(scan_id=scan_id).order_by("id")
|
||||
if last_id is not None:
|
||||
qs = qs.filter(id__gt=last_id)
|
||||
|
||||
findings_batch = list(
|
||||
qs.values(
|
||||
"id",
|
||||
"uid",
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
"first_seen_at",
|
||||
"scan_id",
|
||||
"delta",
|
||||
"status",
|
||||
"status_extended",
|
||||
"severity",
|
||||
"check_id",
|
||||
"check_metadata__checktitle",
|
||||
"muted",
|
||||
"muted_reason",
|
||||
)[:BATCH_SIZE]
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Iteration #{iteration} fetched {len(findings_batch)} findings"
|
||||
)
|
||||
|
||||
if not findings_batch:
|
||||
logger.info(
|
||||
f"No findings returned for iteration #{iteration}; stopping pagination"
|
||||
)
|
||||
break
|
||||
|
||||
last_id = findings_batch[-1]["id"]
|
||||
enriched_batch = _enrich_and_flatten_batch(findings_batch)
|
||||
|
||||
# Yield outside the transaction
|
||||
if enriched_batch:
|
||||
yield enriched_batch
|
||||
|
||||
logger.info(f"Finished fetching findings for scan {scan_id}")
|
||||
|
||||
|
||||
def _enrich_and_flatten_batch(
|
||||
findings_batch: list[dict],
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Fetch resource UIDs for a batch of findings and return flat structure.
|
||||
|
||||
One finding with 3 resources becomes 3 dicts (same output format as before).
|
||||
Must be called within an RLS transaction context.
|
||||
"""
|
||||
finding_ids = [f["id"] for f in findings_batch]
|
||||
|
||||
# Single join: mapping -> resource
|
||||
resource_mappings = ResourceFindingMapping.objects.filter(
|
||||
finding_id__in=finding_ids
|
||||
).values_list("finding_id", "resource__uid")
|
||||
|
||||
# Build finding_id -> [resource_uids] mapping
|
||||
finding_resources = defaultdict(list)
|
||||
for finding_id, resource_uid in resource_mappings:
|
||||
finding_resources[finding_id].append(resource_uid)
|
||||
|
||||
# Flatten: one dict per (finding, resource) pair
|
||||
results = []
|
||||
for f in findings_batch:
|
||||
resource_uids = finding_resources.get(f["id"], [])
|
||||
|
||||
if not resource_uids:
|
||||
continue
|
||||
|
||||
for resource_uid in resource_uids:
|
||||
results.append(
|
||||
{
|
||||
"resource_uid": str(resource_uid),
|
||||
"id": str(f["id"]),
|
||||
"uid": f["uid"],
|
||||
"inserted_at": f["inserted_at"],
|
||||
"updated_at": f["updated_at"],
|
||||
"first_seen_at": f["first_seen_at"],
|
||||
"scan_id": str(f["scan_id"]),
|
||||
"delta": f["delta"],
|
||||
"status": f["status"],
|
||||
"status_extended": f["status_extended"],
|
||||
"severity": f["severity"],
|
||||
"check_id": str(f["check_id"]),
|
||||
"check_title": f["check_metadata__checktitle"],
|
||||
"muted": f["muted"],
|
||||
"muted_reason": f["muted_reason"],
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_findings(
|
||||
neo4j_session: neo4j.Session,
|
||||
findings_batches: Generator[list[dict[str, str]], None, None],
|
||||
prowler_api_provider: Provider,
|
||||
config: CartographyConfig,
|
||||
) -> None:
|
||||
replacements = {
|
||||
"__ROOT_NODE_LABEL__": get_root_node_label(prowler_api_provider.provider),
|
||||
"__NODE_UID_FIELD__": get_node_uid_field(prowler_api_provider.provider),
|
||||
}
|
||||
query = INSERT_STATEMENT_TEMPLATE
|
||||
for replace_key, replace_value in replacements.items():
|
||||
query = query.replace(replace_key, replace_value)
|
||||
|
||||
parameters = {
|
||||
"provider_uid": str(prowler_api_provider.uid),
|
||||
"last_updated": config.update_tag,
|
||||
"prowler_version": ProwlerConfig.prowler_version,
|
||||
}
|
||||
|
||||
batch_num = 0
|
||||
total_records = 0
|
||||
for batch in findings_batches:
|
||||
batch_num += 1
|
||||
batch_size = len(batch)
|
||||
total_records += batch_size
|
||||
|
||||
parameters["findings_data"] = batch
|
||||
|
||||
logger.info(f"Loading findings batch {batch_num} ({batch_size} records)")
|
||||
neo4j_session.run(query, parameters)
|
||||
|
||||
logger.info(f"Finished loading {total_records} records in {batch_num} batches")
|
||||
|
||||
|
||||
def cleanup_findings(
|
||||
neo4j_session: neo4j.Session,
|
||||
prowler_api_provider: Provider,
|
||||
config: CartographyConfig,
|
||||
) -> None:
|
||||
parameters = {
|
||||
"provider_uid": str(prowler_api_provider.uid),
|
||||
"last_updated": config.update_tag,
|
||||
"batch_size": BATCH_SIZE,
|
||||
}
|
||||
|
||||
batch = 1
|
||||
deleted_count = 1
|
||||
while deleted_count > 0:
|
||||
logger.info(f"Cleaning findings batch {batch}")
|
||||
|
||||
result = neo4j_session.run(CLEANUP_STATEMENT, parameters)
|
||||
|
||||
deleted_count = result.single().get("deleted_findings_count", 0)
|
||||
batch += 1
|
||||
@@ -0,0 +1,134 @@
|
||||
# Cypher query templates for Attack Paths operations
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
PROWLER_FINDING_LABEL,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
)
|
||||
|
||||
|
||||
def render_cypher_template(template: str, replacements: dict[str, str]) -> str:
|
||||
"""
|
||||
Render a Cypher query template by replacing placeholders.
|
||||
|
||||
Placeholders use `__DOUBLE_UNDERSCORE__` format to avoid conflicts
|
||||
with Cypher syntax.
|
||||
"""
|
||||
query = template
|
||||
for placeholder, value in replacements.items():
|
||||
query = query.replace(placeholder, value)
|
||||
return query
|
||||
|
||||
|
||||
# Findings queries (used by findings.py)
|
||||
# ---------------------------------------
|
||||
|
||||
ADD_RESOURCE_LABEL_TEMPLATE = """
|
||||
MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r)
|
||||
WHERE NOT r:__ROOT_LABEL__ AND NOT r:__RESOURCE_LABEL__
|
||||
WITH r LIMIT $batch_size
|
||||
SET r:__RESOURCE_LABEL__
|
||||
RETURN COUNT(r) AS labeled_count
|
||||
"""
|
||||
|
||||
INSERT_FINDING_TEMPLATE = f"""
|
||||
MATCH (account:__ROOT_NODE_LABEL__ {{id: $provider_uid}})
|
||||
UNWIND $findings_data AS finding_data
|
||||
|
||||
OPTIONAL MATCH (account)-->(resource_by_uid:__RESOURCE_LABEL__)
|
||||
WHERE resource_by_uid.__NODE_UID_FIELD__ = finding_data.resource_uid
|
||||
WITH account, finding_data, resource_by_uid
|
||||
|
||||
OPTIONAL MATCH (account)-->(resource_by_id:__RESOURCE_LABEL__)
|
||||
WHERE resource_by_uid IS NULL
|
||||
AND resource_by_id.id = finding_data.resource_uid
|
||||
WITH account, finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource
|
||||
WHERE resource IS NOT NULL
|
||||
|
||||
MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}})
|
||||
ON CREATE SET
|
||||
finding.id = finding_data.id,
|
||||
finding.uid = finding_data.uid,
|
||||
finding.inserted_at = finding_data.inserted_at,
|
||||
finding.updated_at = finding_data.updated_at,
|
||||
finding.first_seen_at = finding_data.first_seen_at,
|
||||
finding.scan_id = finding_data.scan_id,
|
||||
finding.delta = finding_data.delta,
|
||||
finding.status = finding_data.status,
|
||||
finding.status_extended = finding_data.status_extended,
|
||||
finding.severity = finding_data.severity,
|
||||
finding.check_id = finding_data.check_id,
|
||||
finding.check_title = finding_data.check_title,
|
||||
finding.muted = finding_data.muted,
|
||||
finding.muted_reason = finding_data.muted_reason,
|
||||
finding.provider_uid = $provider_uid,
|
||||
finding.firstseen = timestamp(),
|
||||
finding.lastupdated = $last_updated,
|
||||
finding._module_name = 'cartography:prowler',
|
||||
finding._module_version = $prowler_version
|
||||
ON MATCH SET
|
||||
finding.status = finding_data.status,
|
||||
finding.status_extended = finding_data.status_extended,
|
||||
finding.lastupdated = $last_updated
|
||||
|
||||
MERGE (resource)-[rel:HAS_FINDING]->(finding)
|
||||
ON CREATE SET
|
||||
rel.provider_uid = $provider_uid,
|
||||
rel.firstseen = timestamp(),
|
||||
rel.lastupdated = $last_updated,
|
||||
rel._module_name = 'cartography:prowler',
|
||||
rel._module_version = $prowler_version
|
||||
ON MATCH SET
|
||||
rel.lastupdated = $last_updated
|
||||
"""
|
||||
|
||||
CLEANUP_FINDINGS_TEMPLATE = f"""
|
||||
MATCH (finding:{PROWLER_FINDING_LABEL} {{provider_uid: $provider_uid}})
|
||||
WHERE finding.lastupdated < $last_updated
|
||||
|
||||
WITH finding LIMIT $batch_size
|
||||
|
||||
DETACH DELETE finding
|
||||
|
||||
RETURN COUNT(finding) AS deleted_findings_count
|
||||
"""
|
||||
|
||||
# Sync queries (used by sync.py)
|
||||
# -------------------------------
|
||||
|
||||
NODE_FETCH_QUERY = """
|
||||
MATCH (n)
|
||||
WHERE id(n) > $last_id
|
||||
RETURN id(n) AS internal_id,
|
||||
elementId(n) AS element_id,
|
||||
labels(n) AS labels,
|
||||
properties(n) AS props
|
||||
ORDER BY internal_id
|
||||
LIMIT $batch_size
|
||||
"""
|
||||
|
||||
RELATIONSHIPS_FETCH_QUERY = """
|
||||
MATCH ()-[r]->()
|
||||
WHERE id(r) > $last_id
|
||||
RETURN id(r) AS internal_id,
|
||||
type(r) AS rel_type,
|
||||
elementId(startNode(r)) AS start_element_id,
|
||||
elementId(endNode(r)) AS end_element_id,
|
||||
properties(r) AS props
|
||||
ORDER BY internal_id
|
||||
LIMIT $batch_size
|
||||
"""
|
||||
|
||||
NODE_SYNC_TEMPLATE = """
|
||||
UNWIND $rows AS row
|
||||
MERGE (n:__NODE_LABELS__ {provider_element_id: row.provider_element_id})
|
||||
SET n += row.props
|
||||
SET n.provider_id = $provider_id
|
||||
"""
|
||||
|
||||
RELATIONSHIP_SYNC_TEMPLATE = f"""
|
||||
UNWIND $rows AS row
|
||||
MATCH (s:{PROVIDER_RESOURCE_LABEL} {{provider_element_id: row.start_element_id}})
|
||||
MATCH (t:{PROVIDER_RESOURCE_LABEL} {{provider_element_id: row.end_element_id}})
|
||||
MERGE (s)-[r:__REL_TYPE__ {{provider_element_id: row.provider_element_id}}]->(t)
|
||||
SET r += row.props
|
||||
SET r.provider_id = $provider_id
|
||||
"""
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from cartography.config import Config as CartographyConfig
|
||||
from cartography.intel import analysis as cartography_analysis
|
||||
@@ -17,7 +16,8 @@ from api.models import (
|
||||
StateChoices,
|
||||
)
|
||||
from api.utils import initialize_prowler_provider
|
||||
from tasks.jobs.attack_paths import aws, db_utils, prowler, utils
|
||||
from tasks.jobs.attack_paths import db_utils, findings, sync, utils
|
||||
from tasks.jobs.attack_paths.config import get_cartography_ingestion_function
|
||||
|
||||
# Without this Celery goes crazy with Cartography logging
|
||||
logging.getLogger("cartography").setLevel(logging.ERROR)
|
||||
@@ -25,18 +25,10 @@ logging.getLogger("neo4j").propagate = False
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
CARTOGRAPHY_INGESTION_FUNCTIONS: dict[str, Callable] = {
|
||||
"aws": aws.start_aws_ingestion,
|
||||
}
|
||||
|
||||
|
||||
def get_cartography_ingestion_function(provider_type: str) -> Callable | None:
|
||||
return CARTOGRAPHY_INGESTION_FUNCTIONS.get(provider_type)
|
||||
|
||||
|
||||
def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Code based on Cartography version 0.122.0, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`,
|
||||
Code based on Cartography, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`,
|
||||
`cartography.sync.run_with_config` and `cartography.sync.Sync.run`.
|
||||
"""
|
||||
ingestion_exceptions = {} # This will hold any exceptions raised during ingestion
|
||||
@@ -76,22 +68,36 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
tenant_id, scan_id, prowler_api_provider.id
|
||||
)
|
||||
|
||||
tmp_database_name = graph_database.get_database_name(
|
||||
attack_paths_scan.id, temporary=True
|
||||
)
|
||||
tenant_database_name = graph_database.get_database_name(
|
||||
prowler_api_provider.tenant_id
|
||||
)
|
||||
|
||||
# While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object
|
||||
cartography_config = CartographyConfig(
|
||||
tmp_cartography_config = CartographyConfig(
|
||||
neo4j_uri=graph_database.get_uri(),
|
||||
neo4j_database=graph_database.get_database_name(attack_paths_scan.id),
|
||||
neo4j_database=tmp_database_name,
|
||||
update_tag=int(time.time()),
|
||||
)
|
||||
tenant_cartography_config = CartographyConfig(
|
||||
neo4j_uri=tmp_cartography_config.neo4j_uri,
|
||||
neo4j_database=tenant_database_name,
|
||||
update_tag=tmp_cartography_config.update_tag,
|
||||
)
|
||||
|
||||
# Starting the Attack Paths scan
|
||||
db_utils.starting_attack_paths_scan(attack_paths_scan, task_id, cartography_config)
|
||||
db_utils.starting_attack_paths_scan(
|
||||
attack_paths_scan, task_id, tenant_cartography_config
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Creating Neo4j database {cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
|
||||
f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
|
||||
)
|
||||
|
||||
graph_database.create_database(cartography_config.neo4j_database)
|
||||
graph_database.create_database(tmp_cartography_config.neo4j_database)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 1)
|
||||
|
||||
logger.info(
|
||||
@@ -99,18 +105,18 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}"
|
||||
)
|
||||
with graph_database.get_session(
|
||||
cartography_config.neo4j_database
|
||||
) as neo4j_session:
|
||||
tmp_cartography_config.neo4j_database
|
||||
) as tmp_neo4j_session:
|
||||
# Indexes creation
|
||||
cartography_create_indexes.run(neo4j_session, cartography_config)
|
||||
prowler.create_indexes(neo4j_session)
|
||||
cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config)
|
||||
findings.create_findings_indexes(tmp_neo4j_session)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2)
|
||||
|
||||
# The real scan, where iterates over cloud services
|
||||
ingestion_exceptions = _call_within_event_loop(
|
||||
ingestion_exceptions = utils.call_within_event_loop(
|
||||
cartography_ingestion_function,
|
||||
neo4j_session,
|
||||
cartography_config,
|
||||
tmp_neo4j_session,
|
||||
tmp_cartography_config,
|
||||
prowler_api_provider,
|
||||
prowler_sdk_provider,
|
||||
attack_paths_scan,
|
||||
@@ -120,43 +126,92 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
logger.info(
|
||||
f"Syncing Cartography ontology for AWS account {prowler_api_provider.uid}"
|
||||
)
|
||||
cartography_ontology.run(neo4j_session, cartography_config)
|
||||
cartography_ontology.run(tmp_neo4j_session, tmp_cartography_config)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95)
|
||||
|
||||
logger.info(
|
||||
f"Syncing Cartography analysis for AWS account {prowler_api_provider.uid}"
|
||||
)
|
||||
cartography_analysis.run(neo4j_session, cartography_config)
|
||||
cartography_analysis.run(tmp_neo4j_session, tmp_cartography_config)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96)
|
||||
|
||||
# Adding Prowler nodes and relationships
|
||||
logger.info(
|
||||
f"Syncing Prowler analysis for AWS account {prowler_api_provider.uid}"
|
||||
)
|
||||
prowler.analysis(
|
||||
neo4j_session, prowler_api_provider, scan_id, cartography_config
|
||||
findings.analysis(
|
||||
tmp_neo4j_session, prowler_api_provider, scan_id, tmp_cartography_config
|
||||
)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 97)
|
||||
|
||||
logger.info(
|
||||
f"Clearing Neo4j cache for database {cartography_config.neo4j_database}"
|
||||
f"Clearing Neo4j cache for database {tmp_cartography_config.neo4j_database}"
|
||||
)
|
||||
graph_database.clear_cache(cartography_config.neo4j_database)
|
||||
graph_database.clear_cache(tmp_cartography_config.neo4j_database)
|
||||
|
||||
logger.info(
|
||||
f"Ensuring tenant database {tenant_database_name}, and its indexes, exists for tenant {prowler_api_provider.tenant_id}"
|
||||
)
|
||||
graph_database.create_database(tenant_database_name)
|
||||
with graph_database.get_session(tenant_database_name) as tenant_neo4j_session:
|
||||
cartography_create_indexes.run(
|
||||
tenant_neo4j_session, tenant_cartography_config
|
||||
)
|
||||
findings.create_findings_indexes(tenant_neo4j_session)
|
||||
sync.create_sync_indexes(tenant_neo4j_session)
|
||||
|
||||
logger.info(f"Deleting existing provider graph in {tenant_database_name}")
|
||||
graph_database.drop_subgraph(
|
||||
database=tenant_database_name,
|
||||
provider_id=str(prowler_api_provider.id),
|
||||
)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 98)
|
||||
|
||||
logger.info(
|
||||
f"Syncing graph from {tmp_database_name} into {tenant_database_name}"
|
||||
)
|
||||
sync.sync_graph(
|
||||
source_database=tmp_database_name,
|
||||
target_database=tenant_database_name,
|
||||
provider_id=str(prowler_api_provider.id),
|
||||
)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99)
|
||||
|
||||
logger.info(f"Clearing Neo4j cache for database {tenant_database_name}")
|
||||
graph_database.clear_cache(tenant_database_name)
|
||||
|
||||
logger.info(
|
||||
f"Completed Cartography ({attack_paths_scan.id}) for "
|
||||
f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}"
|
||||
)
|
||||
|
||||
# Handling databases changes
|
||||
# TODO
|
||||
# This piece of code delete old Neo4j databases for this tenant's provider
|
||||
# When we clean all of these databases we need to:
|
||||
# - Delete this block
|
||||
# - Delete function from `db_utils` the functions get_old_attack_paths_scans` & `update_old_attack_paths_scan`
|
||||
# - Remove `graph_database` & `is_graph_database_deleted` from the AttackPathsScan model:
|
||||
# - Check indexes
|
||||
# - Create migration
|
||||
# - The use of `attack_paths_scan.graph_database` on `views` and `views_helpers`
|
||||
# - Tests
|
||||
old_attack_paths_scans = db_utils.get_old_attack_paths_scans(
|
||||
prowler_api_provider.tenant_id,
|
||||
prowler_api_provider.id,
|
||||
attack_paths_scan.id,
|
||||
)
|
||||
for old_attack_paths_scan in old_attack_paths_scans:
|
||||
graph_database.drop_database(old_attack_paths_scan.graph_database)
|
||||
old_graph_database = old_attack_paths_scan.graph_database
|
||||
if old_graph_database and old_graph_database != tenant_database_name:
|
||||
logger.info(
|
||||
f"Dropping old Neo4j database {old_graph_database} for provider {prowler_api_provider.id}"
|
||||
)
|
||||
graph_database.drop_database(old_graph_database)
|
||||
db_utils.update_old_attack_paths_scan(old_attack_paths_scan)
|
||||
|
||||
logger.info(f"Dropping temporary Neo4j database {tmp_database_name}")
|
||||
graph_database.drop_database(tmp_database_name)
|
||||
|
||||
db_utils.finish_attack_paths_scan(
|
||||
attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions
|
||||
)
|
||||
@@ -168,30 +223,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
ingestion_exceptions["global_cartography_error"] = exception_message
|
||||
|
||||
# Handling databases changes
|
||||
graph_database.drop_database(cartography_config.neo4j_database)
|
||||
graph_database.drop_database(tmp_cartography_config.neo4j_database)
|
||||
db_utils.finish_attack_paths_scan(
|
||||
attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _call_within_event_loop(fn, *args, **kwargs):
|
||||
"""
|
||||
Cartography needs a running event loop, so assuming there is none (Celery task or even regular DRF endpoint),
|
||||
let's create a new one and set it as the current event loop for this thread.
|
||||
"""
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
finally:
|
||||
try:
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to shutdown async generators cleanly: {e}")
|
||||
|
||||
loop.close()
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Graph sync operations for Attack Paths.
|
||||
|
||||
This module handles syncing graph data from temporary scan databases
|
||||
to the tenant database, adding provider isolation labels and properties.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from tasks.jobs.attack_paths.config import BATCH_SIZE, PROVIDER_RESOURCE_LABEL
|
||||
from tasks.jobs.attack_paths.indexes import IndexType, create_indexes
|
||||
from tasks.jobs.attack_paths.queries import (
|
||||
NODE_FETCH_QUERY,
|
||||
NODE_SYNC_TEMPLATE,
|
||||
RELATIONSHIP_SYNC_TEMPLATE,
|
||||
RELATIONSHIPS_FETCH_QUERY,
|
||||
render_cypher_template,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def create_sync_indexes(neo4j_session) -> None:
|
||||
"""Create indexes for provider resource sync operations."""
|
||||
create_indexes(neo4j_session, IndexType.SYNC)
|
||||
|
||||
|
||||
def sync_graph(
|
||||
source_database: str,
|
||||
target_database: str,
|
||||
provider_id: str,
|
||||
) -> dict[str, int]:
|
||||
"""
|
||||
Sync all nodes and relationships from source to target database.
|
||||
|
||||
Args:
|
||||
`source_database`: The temporary scan database
|
||||
`target_database`: The tenant database
|
||||
`provider_id`: The provider ID for isolation
|
||||
|
||||
Returns:
|
||||
Dict with counts of synced nodes and relationships
|
||||
"""
|
||||
nodes_synced = sync_nodes(
|
||||
source_database,
|
||||
target_database,
|
||||
provider_id,
|
||||
)
|
||||
relationships_synced = sync_relationships(
|
||||
source_database,
|
||||
target_database,
|
||||
provider_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"nodes": nodes_synced,
|
||||
"relationships": relationships_synced,
|
||||
}
|
||||
|
||||
|
||||
def sync_nodes(
|
||||
source_database: str,
|
||||
target_database: str,
|
||||
provider_id: str,
|
||||
) -> int:
|
||||
"""
|
||||
Sync nodes from source to target database.
|
||||
|
||||
Adds `ProviderResource` label and `provider_id` property to all nodes.
|
||||
"""
|
||||
last_id = -1
|
||||
total_synced = 0
|
||||
|
||||
with (
|
||||
graph_database.get_session(source_database) as source_session,
|
||||
graph_database.get_session(target_database) as target_session,
|
||||
):
|
||||
while True:
|
||||
rows = list(
|
||||
source_session.run(
|
||||
NODE_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": BATCH_SIZE},
|
||||
)
|
||||
)
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
last_id = rows[-1]["internal_id"]
|
||||
|
||||
grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
labels = tuple(sorted(set(row["labels"] or [])))
|
||||
props = dict(row["props"] or {})
|
||||
_strip_internal_properties(props)
|
||||
provider_element_id = f"{provider_id}:{row['element_id']}"
|
||||
grouped[labels].append(
|
||||
{
|
||||
"provider_element_id": provider_element_id,
|
||||
"props": props,
|
||||
}
|
||||
)
|
||||
|
||||
for labels, batch in grouped.items():
|
||||
label_set = set(labels)
|
||||
label_set.add(PROVIDER_RESOURCE_LABEL)
|
||||
node_labels = ":".join(f"`{label}`" for label in sorted(label_set))
|
||||
|
||||
query = render_cypher_template(
|
||||
NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels}
|
||||
)
|
||||
target_session.run(
|
||||
query,
|
||||
{
|
||||
"rows": batch,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
)
|
||||
|
||||
total_synced += len(rows)
|
||||
logger.info(
|
||||
f"Synced {total_synced} nodes from {source_database} to {target_database}"
|
||||
)
|
||||
|
||||
return total_synced
|
||||
|
||||
|
||||
def sync_relationships(
|
||||
source_database: str,
|
||||
target_database: str,
|
||||
provider_id: str,
|
||||
) -> int:
|
||||
"""
|
||||
Sync relationships from source to target database.
|
||||
|
||||
Adds `provider_id` property to all relationships.
|
||||
"""
|
||||
last_id = -1
|
||||
total_synced = 0
|
||||
|
||||
with (
|
||||
graph_database.get_session(source_database) as source_session,
|
||||
graph_database.get_session(target_database) as target_session,
|
||||
):
|
||||
while True:
|
||||
rows = list(
|
||||
source_session.run(
|
||||
RELATIONSHIPS_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": BATCH_SIZE},
|
||||
)
|
||||
)
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
last_id = rows[-1]["internal_id"]
|
||||
|
||||
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
props = dict(row["props"] or {})
|
||||
_strip_internal_properties(props)
|
||||
rel_type = row["rel_type"]
|
||||
grouped[rel_type].append(
|
||||
{
|
||||
"start_element_id": f"{provider_id}:{row['start_element_id']}",
|
||||
"end_element_id": f"{provider_id}:{row['end_element_id']}",
|
||||
"provider_element_id": f"{provider_id}:{rel_type}:{row['internal_id']}",
|
||||
"props": props,
|
||||
}
|
||||
)
|
||||
|
||||
for rel_type, batch in grouped.items():
|
||||
query = render_cypher_template(
|
||||
RELATIONSHIP_SYNC_TEMPLATE, {"__REL_TYPE__": rel_type}
|
||||
)
|
||||
target_session.run(
|
||||
query,
|
||||
{
|
||||
"rows": batch,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
)
|
||||
|
||||
total_synced += len(rows)
|
||||
logger.info(
|
||||
f"Synced {total_synced} relationships from {source_database} to {target_database}"
|
||||
)
|
||||
|
||||
return total_synced
|
||||
|
||||
|
||||
def _strip_internal_properties(props: dict[str, Any]) -> None:
|
||||
"""Remove internal properties that shouldn't be copied during sync."""
|
||||
for key in [
|
||||
"provider_element_id",
|
||||
"provider_id",
|
||||
]:
|
||||
props.pop(key, None)
|
||||
@@ -1,10 +1,40 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def stringify_exception(exception: Exception, context: str) -> str:
|
||||
"""Format an exception with timestamp and traceback for logging."""
|
||||
timestamp = datetime.now(tz=timezone.utc)
|
||||
exception_traceback = traceback.TracebackException.from_exception(exception)
|
||||
traceback_string = "".join(exception_traceback.format())
|
||||
return f"{timestamp} - {context}\n{traceback_string}"
|
||||
|
||||
|
||||
def call_within_event_loop(fn, *args, **kwargs):
|
||||
"""
|
||||
Execute a function within a new event loop.
|
||||
|
||||
Cartography needs a running event loop, so assuming there is none
|
||||
(Celery task or even regular DRF endpoint), this creates a new one
|
||||
and sets it as the current event loop for this thread.
|
||||
"""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
finally:
|
||||
try:
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to shutdown async generators cleanly: {e}")
|
||||
|
||||
loop.close()
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
@@ -13,7 +13,6 @@ from api.models import (
|
||||
ScanSummary,
|
||||
Tenant,
|
||||
)
|
||||
from tasks.jobs.attack_paths.db_utils import get_provider_graph_database_names
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -33,13 +32,13 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
Raises:
|
||||
Provider.DoesNotExist: If no instance with the provided primary key exists.
|
||||
"""
|
||||
# Delete the Attack Paths' graph databases related to the provider
|
||||
graph_database_names = get_provider_graph_database_names(tenant_id, pk)
|
||||
# Delete the Attack Paths' graph data related to the provider
|
||||
tenant_database_name = graph_database.get_database_name(tenant_id)
|
||||
try:
|
||||
for graph_database_name in graph_database_names:
|
||||
graph_database.drop_database(graph_database_name)
|
||||
graph_database.drop_subgraph(tenant_database_name, str(pk))
|
||||
|
||||
except graph_database.GraphDatabaseQueryException as gdb_error:
|
||||
logger.error(f"Error deleting Provider databases: {gdb_error}")
|
||||
logger.error(f"Error deleting Provider graph data: {gdb_error}")
|
||||
raise
|
||||
|
||||
# Get all provider related data and delete them in batches
|
||||
@@ -90,6 +89,13 @@ def delete_tenant(pk: str):
|
||||
summary = delete_provider(pk, provider.id)
|
||||
deletion_summary.update(summary)
|
||||
|
||||
try:
|
||||
tenant_database_name = graph_database.get_database_name(pk)
|
||||
graph_database.drop_database(tenant_database_name)
|
||||
except graph_database.GraphDatabaseQueryException as gdb_error:
|
||||
logger.error(f"Error dropping Tenant graph database: {gdb_error}")
|
||||
raise
|
||||
|
||||
Tenant.objects.using(MainRouter.admin_db).filter(id=pk).delete()
|
||||
|
||||
return deletion_summary
|
||||
|
||||
@@ -3,7 +3,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from tasks.jobs.attack_paths import prowler as prowler_module
|
||||
from tasks.jobs.attack_paths import findings as findings_module
|
||||
from tasks.jobs.attack_paths.scan import run as attack_paths_run
|
||||
|
||||
from api.models import (
|
||||
@@ -21,7 +21,65 @@ from prowler.lib.check.models import Severity
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAttackPathsRun:
|
||||
def test_run_success_flow(self, tenants_fixture, providers_fixture, scans_fixture):
|
||||
# Patching with decorators as we got a `SyntaxError: too many statically nested blocks` error if we use context managers
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
)
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.get_old_attack_paths_scans",
|
||||
return_value=[],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
|
||||
@patch("tasks.jobs.attack_paths.scan.sync.sync_graph")
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph")
|
||||
@patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes")
|
||||
@patch("tasks.jobs.attack_paths.scan.findings.analysis")
|
||||
@patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes")
|
||||
@patch("tasks.jobs.attack_paths.scan.cartography_ontology.run")
|
||||
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
|
||||
@patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache")
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_uri",
|
||||
return_value="bolt://neo4j",
|
||||
)
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
|
||||
return_value=MagicMock(_enabled_regions=["us-east-1"]),
|
||||
)
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
)
|
||||
def test_run_success_flow(
|
||||
self,
|
||||
mock_init_provider,
|
||||
mock_get_uri,
|
||||
mock_create_db,
|
||||
mock_clear_cache,
|
||||
mock_cartography_indexes,
|
||||
mock_cartography_analysis,
|
||||
mock_cartography_ontology,
|
||||
mock_findings_indexes,
|
||||
mock_findings_analysis,
|
||||
mock_sync_indexes,
|
||||
mock_drop_subgraph,
|
||||
mock_sync,
|
||||
mock_starting,
|
||||
mock_update_progress,
|
||||
mock_finish,
|
||||
mock_get_old_scans,
|
||||
mock_event_loop,
|
||||
mock_drop_db,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
scans_fixture,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
@@ -45,66 +103,22 @@ class TestAttackPathsRun:
|
||||
ingestion_fn = MagicMock(return_value=ingestion_result)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
|
||||
return_value=MagicMock(_enabled_regions=["us-east-1"]),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_uri",
|
||||
return_value="bolt://neo4j",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
|
||||
return_value="db-scan-id",
|
||||
side_effect=["db-scan-id", "tenant-db"],
|
||||
) as mock_get_db_name,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.create_database"
|
||||
) as mock_create_db,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_session",
|
||||
return_value=session_ctx,
|
||||
) as mock_get_session,
|
||||
patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.cartography_create_indexes.run"
|
||||
) as mock_cartography_indexes,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.cartography_analysis.run"
|
||||
) as mock_cartography_analysis,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.cartography_ontology.run"
|
||||
) as mock_cartography_ontology,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.prowler.create_indexes"
|
||||
) as mock_prowler_indexes,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.prowler.analysis"
|
||||
) as mock_prowler_analysis,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
|
||||
return_value=attack_paths_scan,
|
||||
) as mock_retrieve_scan,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"
|
||||
) as mock_starting,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress"
|
||||
) as mock_update_progress,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan"
|
||||
) as mock_finish,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
|
||||
return_value=ingestion_fn,
|
||||
) as mock_get_ingestion,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan._call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
) as mock_event_loop,
|
||||
):
|
||||
result = attack_paths_run(str(tenant.id), str(scan.id), "task-123")
|
||||
|
||||
@@ -112,29 +126,40 @@ class TestAttackPathsRun:
|
||||
mock_retrieve_scan.assert_called_once_with(str(tenant.id), str(scan.id))
|
||||
mock_starting.assert_called_once()
|
||||
config = mock_starting.call_args[0][2]
|
||||
assert config.neo4j_database == "db-scan-id"
|
||||
assert config.neo4j_database == "tenant-db"
|
||||
mock_get_db_name.assert_has_calls(
|
||||
[call(attack_paths_scan.id, temporary=True), call(provider.tenant_id)]
|
||||
)
|
||||
|
||||
mock_create_db.assert_called_once_with("db-scan-id")
|
||||
mock_get_session.assert_called_once_with("db-scan-id")
|
||||
mock_cartography_indexes.assert_called_once_with(mock_session, config)
|
||||
mock_prowler_indexes.assert_called_once_with(mock_session)
|
||||
mock_cartography_analysis.assert_called_once_with(mock_session, config)
|
||||
mock_cartography_ontology.assert_called_once_with(mock_session, config)
|
||||
mock_prowler_analysis.assert_called_once_with(
|
||||
mock_session,
|
||||
provider,
|
||||
str(scan.id),
|
||||
config,
|
||||
mock_create_db.assert_has_calls([call("db-scan-id"), call("tenant-db")])
|
||||
mock_get_session.assert_has_calls([call("db-scan-id"), call("tenant-db")])
|
||||
assert mock_cartography_indexes.call_count == 2
|
||||
mock_findings_indexes.assert_has_calls([call(mock_session), call(mock_session)])
|
||||
mock_sync_indexes.assert_called_once_with(mock_session)
|
||||
# These use tmp_cartography_config (neo4j_database="db-scan-id")
|
||||
mock_cartography_analysis.assert_called_once()
|
||||
mock_cartography_ontology.assert_called_once()
|
||||
mock_findings_analysis.assert_called_once()
|
||||
mock_drop_subgraph.assert_called_once_with(
|
||||
database="tenant-db",
|
||||
provider_id=str(provider.id),
|
||||
)
|
||||
mock_sync.assert_called_once_with(
|
||||
source_database="db-scan-id",
|
||||
target_database="tenant-db",
|
||||
provider_id=str(provider.id),
|
||||
)
|
||||
mock_get_ingestion.assert_called_once_with(provider.provider)
|
||||
mock_event_loop.assert_called_once()
|
||||
mock_update_progress.assert_any_call(attack_paths_scan, 1)
|
||||
mock_update_progress.assert_any_call(attack_paths_scan, 2)
|
||||
mock_update_progress.assert_any_call(attack_paths_scan, 95)
|
||||
mock_update_progress.assert_any_call(attack_paths_scan, 97)
|
||||
mock_update_progress.assert_any_call(attack_paths_scan, 98)
|
||||
mock_update_progress.assert_any_call(attack_paths_scan, 99)
|
||||
mock_finish.assert_called_once_with(
|
||||
attack_paths_scan, StateChoices.COMPLETED, ingestion_result
|
||||
)
|
||||
mock_get_db_name.assert_called_once_with(attack_paths_scan.id)
|
||||
|
||||
def test_run_failure_marks_scan_failed(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
@@ -181,8 +206,8 @@ class TestAttackPathsRun:
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"),
|
||||
patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"),
|
||||
patch("tasks.jobs.attack_paths.scan.prowler.create_indexes"),
|
||||
patch("tasks.jobs.attack_paths.scan.prowler.analysis"),
|
||||
patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes"),
|
||||
patch("tasks.jobs.attack_paths.scan.findings.analysis"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
|
||||
return_value=attack_paths_scan,
|
||||
@@ -194,12 +219,13 @@ class TestAttackPathsRun:
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan"
|
||||
) as mock_finish,
|
||||
patch("tasks.jobs.attack_paths.scan.graph_database.drop_database"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
|
||||
return_value=ingestion_fn,
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan._call_within_event_loop",
|
||||
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
),
|
||||
patch(
|
||||
@@ -261,15 +287,17 @@ class TestAttackPathsRun:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAttackPathsProwlerHelpers:
|
||||
def test_create_indexes_executes_all_statements(self):
|
||||
class TestAttackPathsFindingsHelpers:
|
||||
def test_create_findings_indexes_executes_all_statements(self):
|
||||
mock_session = MagicMock()
|
||||
with patch("tasks.jobs.attack_paths.prowler.run_write_query") as mock_run_write:
|
||||
prowler_module.create_indexes(mock_session)
|
||||
with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write:
|
||||
findings_module.create_findings_indexes(mock_session)
|
||||
|
||||
assert mock_run_write.call_count == len(prowler_module.INDEX_STATEMENTS)
|
||||
from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS
|
||||
|
||||
assert mock_run_write.call_count == len(FINDINGS_INDEX_STATEMENTS)
|
||||
mock_run_write.assert_has_calls(
|
||||
[call(mock_session, stmt) for stmt in prowler_module.INDEX_STATEMENTS]
|
||||
[call(mock_session, stmt) for stmt in FINDINGS_INDEX_STATEMENTS]
|
||||
)
|
||||
|
||||
def test_load_findings_batches_requests(self, providers_fixture):
|
||||
@@ -277,25 +305,35 @@ class TestAttackPathsProwlerHelpers:
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
|
||||
# Create a generator that yields two batches
|
||||
# Create mock Finding objects with to_dict() method
|
||||
mock_finding_1 = MagicMock()
|
||||
mock_finding_1.to_dict.return_value = {"id": "1", "resource_uid": "r-1"}
|
||||
mock_finding_2 = MagicMock()
|
||||
mock_finding_2.to_dict.return_value = {"id": "2", "resource_uid": "r-2"}
|
||||
|
||||
# Create a generator that yields two batches of Finding instances
|
||||
def findings_generator():
|
||||
yield [{"id": "1", "resource_uid": "r-1"}]
|
||||
yield [{"id": "2", "resource_uid": "r-2"}]
|
||||
yield [mock_finding_1]
|
||||
yield [mock_finding_2]
|
||||
|
||||
config = SimpleNamespace(update_tag=12345)
|
||||
mock_session = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.prowler.get_root_node_label",
|
||||
"tasks.jobs.attack_paths.findings.get_root_node_label",
|
||||
return_value="AWSAccount",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.prowler.get_node_uid_field",
|
||||
"tasks.jobs.attack_paths.findings.get_node_uid_field",
|
||||
return_value="arn",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
|
||||
return_value="AWSResource",
|
||||
),
|
||||
):
|
||||
prowler_module.load_findings(
|
||||
findings_module.load_findings(
|
||||
mock_session, findings_generator(), provider, config
|
||||
)
|
||||
|
||||
@@ -317,14 +355,14 @@ class TestAttackPathsProwlerHelpers:
|
||||
second_batch.single.return_value = {"deleted_findings_count": 0}
|
||||
mock_session.run.side_effect = [first_batch, second_batch]
|
||||
|
||||
prowler_module.cleanup_findings(mock_session, provider, config)
|
||||
findings_module.cleanup_findings(mock_session, provider, config)
|
||||
|
||||
assert mock_session.run.call_count == 2
|
||||
params = mock_session.run.call_args.args[1]
|
||||
assert params["provider_uid"] == str(provider.uid)
|
||||
assert params["last_updated"] == config.update_tag
|
||||
|
||||
def test_get_provider_last_scan_findings_returns_latest_scan_data(
|
||||
def test_stream_findings_with_resources_returns_latest_scan_data(
|
||||
self,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
@@ -402,15 +440,18 @@ class TestAttackPathsProwlerHelpers:
|
||||
|
||||
latest_scan.refresh_from_db()
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.prowler.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
), patch(
|
||||
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
),
|
||||
):
|
||||
# Generator yields batches, collect all findings from all batches
|
||||
findings_batches = prowler_module.get_provider_last_scan_findings(
|
||||
findings_batches = findings_module.stream_findings_with_resources(
|
||||
provider,
|
||||
str(latest_scan.id),
|
||||
)
|
||||
@@ -419,18 +460,18 @@ class TestAttackPathsProwlerHelpers:
|
||||
findings_data.extend(batch)
|
||||
|
||||
assert len(findings_data) == 1
|
||||
finding_dict = findings_data[0]
|
||||
assert finding_dict["id"] == str(finding.id)
|
||||
assert finding_dict["resource_uid"] == resource.uid
|
||||
assert finding_dict["check_title"] == "Check title"
|
||||
assert finding_dict["scan_id"] == str(latest_scan.id)
|
||||
finding_result = findings_data[0]
|
||||
assert finding_result.id == str(finding.id)
|
||||
assert finding_result.resource_uid == resource.uid
|
||||
assert finding_result.check_title == "Check title"
|
||||
assert finding_result.scan_id == str(latest_scan.id)
|
||||
|
||||
def test_enrich_and_flatten_batch_single_resource(
|
||||
def test_enrich_batch_with_resources_single_resource(
|
||||
self,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""One finding + one resource = one output dict"""
|
||||
"""One finding + one resource = one output Finding instance"""
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
@@ -493,25 +534,27 @@ class TestAttackPathsProwlerHelpers:
|
||||
"muted_reason": finding.muted_reason,
|
||||
}
|
||||
|
||||
# _enrich_and_flatten_batch queries ResourceFindingMapping directly
|
||||
# _enrich_batch_with_resources queries ResourceFindingMapping directly
|
||||
# No RLS mock needed - test DB doesn't enforce RLS policies
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
|
||||
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
):
|
||||
result = prowler_module._enrich_and_flatten_batch([finding_dict])
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["resource_uid"] == resource.uid
|
||||
assert result[0]["id"] == str(finding.id)
|
||||
assert result[0]["status"] == "FAIL"
|
||||
assert result[0].resource_uid == resource.uid
|
||||
assert result[0].id == str(finding.id)
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_enrich_and_flatten_batch_multiple_resources(
|
||||
def test_enrich_batch_with_resources_multiple_resources(
|
||||
self,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""One finding + three resources = three output dicts"""
|
||||
"""One finding + three resources = three output Finding instances"""
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
@@ -579,24 +622,26 @@ class TestAttackPathsProwlerHelpers:
|
||||
"muted_reason": finding.muted_reason,
|
||||
}
|
||||
|
||||
# _enrich_and_flatten_batch queries ResourceFindingMapping directly
|
||||
# _enrich_batch_with_resources queries ResourceFindingMapping directly
|
||||
# No RLS mock needed - test DB doesn't enforce RLS policies
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
|
||||
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
):
|
||||
result = prowler_module._enrich_and_flatten_batch([finding_dict])
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
result_resource_uids = {r["resource_uid"] for r in result}
|
||||
result_resource_uids = {r.resource_uid for r in result}
|
||||
assert result_resource_uids == {r.uid for r in resources}
|
||||
|
||||
# All should have same finding data
|
||||
for r in result:
|
||||
assert r["id"] == str(finding.id)
|
||||
assert r["status"] == "FAIL"
|
||||
assert r.id == str(finding.id)
|
||||
assert r.status == "FAIL"
|
||||
|
||||
def test_enrich_and_flatten_batch_no_resources_skips(
|
||||
def test_enrich_batch_with_resources_no_resources_skips(
|
||||
self,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
@@ -652,12 +697,14 @@ class TestAttackPathsProwlerHelpers:
|
||||
# Mock logger to verify no warning is emitted
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
|
||||
"tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS",
|
||||
"default",
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.prowler.logger") as mock_logger,
|
||||
patch("tasks.jobs.attack_paths.findings.logger") as mock_logger,
|
||||
):
|
||||
result = prowler_module._enrich_and_flatten_batch([finding_dict])
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
)
|
||||
|
||||
assert len(result) == 0
|
||||
mock_logger.warning.assert_not_called()
|
||||
@@ -670,11 +717,11 @@ class TestAttackPathsProwlerHelpers:
|
||||
scan_id = "some-scan-id"
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.attack_paths.prowler.rls_transaction") as mock_rls,
|
||||
patch("tasks.jobs.attack_paths.prowler.Finding") as mock_finding,
|
||||
patch("tasks.jobs.attack_paths.findings.rls_transaction") as mock_rls,
|
||||
patch("tasks.jobs.attack_paths.findings.Finding") as mock_finding,
|
||||
):
|
||||
# Create generator but don't iterate
|
||||
prowler_module.get_provider_last_scan_findings(provider, scan_id)
|
||||
findings_module.stream_findings_with_resources(provider, scan_id)
|
||||
|
||||
# Nothing should be called yet
|
||||
mock_rls.assert_not_called()
|
||||
@@ -695,14 +742,18 @@ class TestAttackPathsProwlerHelpers:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.prowler.get_root_node_label",
|
||||
"tasks.jobs.attack_paths.findings.get_root_node_label",
|
||||
return_value="AWSAccount",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.prowler.get_node_uid_field",
|
||||
"tasks.jobs.attack_paths.findings.get_node_uid_field",
|
||||
return_value="arn",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
|
||||
return_value="AWSResource",
|
||||
),
|
||||
):
|
||||
prowler_module.load_findings(mock_session, empty_gen(), provider, config)
|
||||
findings_module.load_findings(mock_session, empty_gen(), provider, config)
|
||||
|
||||
mock_session.run.assert_not_called()
|
||||
|
||||
@@ -11,14 +11,15 @@ from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
@pytest.mark.django_db
|
||||
class TestDeleteProvider:
|
||||
def test_delete_provider_success(self, providers_fixture):
|
||||
with patch(
|
||||
"tasks.jobs.deletion.get_provider_graph_database_names"
|
||||
) as mock_get_provider_graph_database_names, patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_database"
|
||||
) as mock_drop_database:
|
||||
graph_db_names = ["graph-db-1", "graph-db-2"]
|
||||
mock_get_provider_graph_database_names.return_value = graph_db_names
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
) as mock_get_database_name,
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph"
|
||||
) as mock_drop_subgraph,
|
||||
):
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
result = delete_provider(tenant_id, instance.id)
|
||||
@@ -27,33 +28,32 @@ class TestDeleteProvider:
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
Provider.objects.get(pk=instance.id)
|
||||
|
||||
mock_get_provider_graph_database_names.assert_called_once_with(
|
||||
tenant_id, instance.id
|
||||
)
|
||||
mock_drop_database.assert_has_calls(
|
||||
[call(graph_db_name) for graph_db_name in graph_db_names]
|
||||
mock_get_database_name.assert_called_once_with(tenant_id)
|
||||
mock_drop_subgraph.assert_called_once_with(
|
||||
"tenant-db",
|
||||
str(instance.id),
|
||||
)
|
||||
|
||||
def test_delete_provider_does_not_exist(self, tenants_fixture):
|
||||
with patch(
|
||||
"tasks.jobs.deletion.get_provider_graph_database_names"
|
||||
) as mock_get_provider_graph_database_names, patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_database"
|
||||
) as mock_drop_database:
|
||||
graph_db_names = ["graph-db-1"]
|
||||
mock_get_provider_graph_database_names.return_value = graph_db_names
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
) as mock_get_database_name,
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph"
|
||||
) as mock_drop_subgraph,
|
||||
):
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645"
|
||||
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
delete_provider(tenant_id, non_existent_pk)
|
||||
|
||||
mock_get_provider_graph_database_names.assert_called_once_with(
|
||||
tenant_id, non_existent_pk
|
||||
)
|
||||
mock_drop_database.assert_has_calls(
|
||||
[call(graph_db_name) for graph_db_name in graph_db_names]
|
||||
mock_get_database_name.assert_called_once_with(tenant_id)
|
||||
mock_drop_subgraph.assert_called_once_with(
|
||||
"tenant-db",
|
||||
non_existent_pk,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,21 +63,21 @@ class TestDeleteTenant:
|
||||
"""
|
||||
Test successful deletion of a tenant and its related data.
|
||||
"""
|
||||
with patch(
|
||||
"tasks.jobs.deletion.get_provider_graph_database_names"
|
||||
) as mock_get_provider_graph_database_names, patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_database"
|
||||
) as mock_drop_database:
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
) as mock_get_database_name,
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph"
|
||||
) as mock_drop_subgraph,
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_database"
|
||||
) as mock_drop_database,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
providers = list(Provider.objects.filter(tenant_id=tenant.id))
|
||||
|
||||
graph_db_names_per_provider = [
|
||||
[f"graph-db-{provider.id}"] for provider in providers
|
||||
]
|
||||
mock_get_provider_graph_database_names.side_effect = (
|
||||
graph_db_names_per_provider
|
||||
)
|
||||
|
||||
# Ensure the tenant and related providers exist before deletion
|
||||
assert Tenant.objects.filter(id=tenant.id).exists()
|
||||
assert providers
|
||||
@@ -89,30 +89,42 @@ class TestDeleteTenant:
|
||||
assert not Tenant.objects.filter(id=tenant.id).exists()
|
||||
assert not Provider.objects.filter(tenant_id=tenant.id).exists()
|
||||
|
||||
expected_calls = [
|
||||
call(provider.tenant_id, provider.id) for provider in providers
|
||||
# get_database_name is called once per provider + once for drop_database
|
||||
expected_get_db_calls = [call(tenant.id) for _ in providers] + [
|
||||
call(tenant.id)
|
||||
]
|
||||
mock_get_provider_graph_database_names.assert_has_calls(
|
||||
expected_calls, any_order=True
|
||||
mock_get_database_name.assert_has_calls(
|
||||
expected_get_db_calls, any_order=True
|
||||
)
|
||||
assert mock_get_provider_graph_database_names.call_count == len(
|
||||
expected_calls
|
||||
)
|
||||
expected_drop_calls = [
|
||||
call(graph_db_name[0]) for graph_db_name in graph_db_names_per_provider
|
||||
assert mock_get_database_name.call_count == len(expected_get_db_calls)
|
||||
|
||||
expected_drop_subgraph_calls = [
|
||||
call("tenant-db", str(provider.id)) for provider in providers
|
||||
]
|
||||
mock_drop_database.assert_has_calls(expected_drop_calls, any_order=True)
|
||||
assert mock_drop_database.call_count == len(expected_drop_calls)
|
||||
mock_drop_subgraph.assert_has_calls(
|
||||
expected_drop_subgraph_calls,
|
||||
any_order=True,
|
||||
)
|
||||
assert mock_drop_subgraph.call_count == len(expected_drop_subgraph_calls)
|
||||
|
||||
mock_drop_database.assert_called_once_with("tenant-db")
|
||||
|
||||
def test_delete_tenant_with_no_providers(self, tenants_fixture):
|
||||
"""
|
||||
Test deletion of a tenant with no related providers.
|
||||
"""
|
||||
with patch(
|
||||
"tasks.jobs.deletion.get_provider_graph_database_names"
|
||||
) as mock_get_provider_graph_database_names, patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_database"
|
||||
) as mock_drop_database:
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
) as mock_get_database_name,
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph"
|
||||
) as mock_drop_subgraph,
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_database"
|
||||
) as mock_drop_database,
|
||||
):
|
||||
tenant = tenants_fixture[1] # Assume this tenant has no providers
|
||||
providers = Provider.objects.filter(tenant_id=tenant.id)
|
||||
|
||||
@@ -126,5 +138,7 @@ class TestDeleteTenant:
|
||||
assert deletion_summary == {} # No providers, so empty summary
|
||||
assert not Tenant.objects.filter(id=tenant.id).exists()
|
||||
|
||||
mock_get_provider_graph_database_names.assert_not_called()
|
||||
mock_drop_database.assert_not_called()
|
||||
# get_database_name is called once for drop_database
|
||||
mock_get_database_name.assert_called_once_with(tenant.id)
|
||||
mock_drop_subgraph.assert_not_called()
|
||||
mock_drop_database.assert_called_once_with("tenant-db")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_format3
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ID",
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"REQUIREMENTS_DESCRIPTION",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
].copy()
|
||||
|
||||
return get_section_containers_format3(
|
||||
aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID"
|
||||
)
|
||||
@@ -1119,6 +1119,10 @@ def filter_data(
|
||||
figure=fig,
|
||||
config={"displayModeBar": False},
|
||||
)
|
||||
pie_3 = dcc.Graph(
|
||||
figure=fig,
|
||||
config={"displayModeBar": False},
|
||||
)
|
||||
table = dcc.Graph(figure=fig, config={"displayModeBar": False})
|
||||
|
||||
else:
|
||||
@@ -1175,22 +1179,25 @@ def filter_data(
|
||||
style={"height": "300px", "overflow-y": "auto"},
|
||||
)
|
||||
|
||||
color_bars = [
|
||||
color_mapping_severity[severity]
|
||||
for severity in df1["SEVERITY"].value_counts().index
|
||||
]
|
||||
|
||||
figure_bars = go.Figure(
|
||||
data=[
|
||||
# Prepare bar chart data only if df1 has FAIL findings
|
||||
if len(df1) > 0:
|
||||
color_bars = [
|
||||
color_mapping_severity[severity]
|
||||
for severity in df1["SEVERITY"].value_counts().index
|
||||
]
|
||||
bar_data = [
|
||||
go.Bar(
|
||||
x=df1["SEVERITY"]
|
||||
.value_counts()
|
||||
.index, # assign x as the dataframe column 'x'
|
||||
x=df1["SEVERITY"].value_counts().index,
|
||||
y=df1["SEVERITY"].value_counts().values,
|
||||
marker=dict(color=color_bars),
|
||||
textposition="auto",
|
||||
)
|
||||
],
|
||||
]
|
||||
else:
|
||||
bar_data = []
|
||||
|
||||
figure_bars = go.Figure(
|
||||
data=bar_data,
|
||||
layout=go.Layout(
|
||||
paper_bgcolor="#FFF",
|
||||
font=dict(size=12, color="#292524"),
|
||||
@@ -1560,6 +1567,8 @@ def filter_data(
|
||||
severity_values,
|
||||
severity_filter_options,
|
||||
service_values,
|
||||
provider_values,
|
||||
provider_filter_options,
|
||||
service_filter_options,
|
||||
table_row_values,
|
||||
table_row_options,
|
||||
|
||||
@@ -155,6 +155,8 @@ If you are using AI assistants to help with your contributions, Prowler provides
|
||||
|
||||
- **AGENTS.md Files**: Each component of the Prowler monorepo includes an `AGENTS.md` file that contains specific guidelines for AI agents working on that component. These files provide context about project structure, coding standards, and best practices. When working on a specific component, refer to the relevant `AGENTS.md` file (e.g., `prowler/AGENTS.md`, `ui/AGENTS.md`, `api/AGENTS.md`) to ensure your AI assistant follows the appropriate guidelines.
|
||||
|
||||
- **AI Skills System**: The [AI Skills system](/developer-guide/ai-skills) provides on-demand patterns, templates, and best practices for AI agents. Skills help AI assistants understand Prowler's conventions and generate code that aligns with project standards. The skills are located in the `skills/` directory and are registered in the `AGENTS.md` files.
|
||||
|
||||
These resources help ensure that AI-assisted contributions maintain consistency with Prowler's codebase and development practices.
|
||||
|
||||
### Dependency Management
|
||||
|
||||
@@ -35,6 +35,16 @@ Create tests that generate both a passing (`PASS`) and a failing (`FAIL`) result
|
||||
3. Multi-Resource Evaluations:
|
||||
Design tests with multiple resources to verify check behavior and ensure the correct number of findings.
|
||||
|
||||
## Test File Naming Conventions
|
||||
|
||||
Test files follow the pattern `{service}_{check_name}_test.py` for checks and `{service}_service_test.py` for services.
|
||||
|
||||
### Duplicate Names Across Providers
|
||||
|
||||
When a test file name already exists in another provider, add your provider prefix to avoid conflicts. A GitHub Action will fail if duplicate names are detected.
|
||||
|
||||
**Example:** If `kms_service_test.py` already exists in AWS, name your Oracle Cloud test `oraclecloud_kms_service_test.py`.
|
||||
|
||||
## Running Prowler Tests
|
||||
|
||||
To execute the Prowler test suite, install the necessary dependencies listed in the `pyproject.toml` file.
|
||||
|
||||
@@ -267,6 +267,13 @@
|
||||
"user-guide/providers/oci/getting-started-oci",
|
||||
"user-guide/providers/oci/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "OpenStack",
|
||||
"pages": [
|
||||
"user-guide/providers/openstack/getting-started-openstack",
|
||||
"user-guide/providers/openstack/authentication"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -115,8 +115,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.17.0"
|
||||
PROWLER_API_VERSION="5.17.0"
|
||||
PROWLER_UI_VERSION="5.18.1"
|
||||
PROWLER_API_VERSION="5.18.1"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -86,3 +86,81 @@ 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'Cloudflare Authentication'
|
||||
title: 'Cloudflare Authentication in Prowler'
|
||||
---
|
||||
|
||||
Prowler for Cloudflare supports the following authentication methods:
|
||||
|
||||
@@ -38,6 +38,7 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained
|
||||
|
||||
4. **Configure Token Settings**
|
||||
- **Token name**: Give your token a descriptive name (e.g., "Prowler Security Scanner")
|
||||
- **Resource owner**: Select the account that owns the resources to scan — either a personal account or a specific organization
|
||||
- **Expiration**: Set an appropriate expiration date (recommended: 90 days or less)
|
||||
- **Repository access**: Choose "All repositories" or "Only select repositories" based on your needs
|
||||
|
||||
@@ -56,11 +57,11 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained
|
||||
- **Metadata**: Read-only access
|
||||
- **Pull requests**: Read-only access
|
||||
|
||||
- **Organization permissions:**
|
||||
- **Organization permissions** (available when an organization is selected as Resource Owner):
|
||||
- **Administration**: Read-only access
|
||||
- **Members**: Read-only access
|
||||
|
||||
- **Account permissions:**
|
||||
- **Account permissions** (available when a personal account is selected as Resource Owner):
|
||||
- **Email addresses**: Read-only access
|
||||
|
||||
6. **Copy and Store the Token**
|
||||
|
||||
@@ -54,7 +54,7 @@ title: 'Getting Started with GitHub'
|
||||
</Tabs>
|
||||
## Prowler CLI
|
||||
|
||||
### Automatic Login Method Detection
|
||||
### Authentication
|
||||
|
||||
If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence:
|
||||
|
||||
@@ -68,15 +68,15 @@ Ensure the corresponding environment variables are set up before running Prowler
|
||||
</Note>
|
||||
For more details on how to set up authentication with GitHub, see [Authentication > GitHub](/user-guide/providers/github/authentication).
|
||||
|
||||
### Personal Access Token (PAT)
|
||||
#### Personal Access Token (PAT)
|
||||
|
||||
Use this method by providing your personal access token directly.
|
||||
Use this method by providing a personal access token directly.
|
||||
|
||||
```console
|
||||
prowler github --personal-access-token pat
|
||||
```
|
||||
|
||||
### OAuth App Token
|
||||
#### OAuth App Token
|
||||
|
||||
Authenticate using an OAuth app token.
|
||||
|
||||
@@ -84,9 +84,62 @@ Authenticate using an OAuth app token.
|
||||
prowler github --oauth-app-token oauth_token
|
||||
```
|
||||
|
||||
### GitHub App Credentials
|
||||
#### GitHub App Credentials
|
||||
|
||||
Use GitHub App credentials by specifying the App ID and the private key path.
|
||||
|
||||
```console
|
||||
prowler github --github-app-id app_id --github-app-key-path app_key_path
|
||||
```
|
||||
|
||||
### Scan Scoping
|
||||
|
||||
By default, Prowler scans all repositories accessible to the authenticated user or organization. To limit the scan to specific repositories or organizations, use the following flags.
|
||||
|
||||
#### Scanning Specific Repositories
|
||||
|
||||
To restrict the scan to one or more repositories, use the `--repository` flag followed by the repository name(s) in `owner/repo-name` format:
|
||||
|
||||
```console
|
||||
prowler github --repository owner/repo-name
|
||||
```
|
||||
|
||||
To scan multiple repositories, specify them as space-separated arguments:
|
||||
|
||||
```console
|
||||
prowler github --repository owner/repo-name-1 owner/repo-name-2
|
||||
```
|
||||
|
||||
#### Scanning Specific Organizations
|
||||
|
||||
To restrict the scan to one or more organizations or user accounts, use the `--organization` flag:
|
||||
|
||||
```console
|
||||
prowler github --organization my-organization
|
||||
```
|
||||
|
||||
To scan multiple organizations, specify them as space-separated arguments:
|
||||
|
||||
```console
|
||||
prowler github --organization org-1 org-2
|
||||
```
|
||||
|
||||
#### Scanning Specific Repositories Within an Organization
|
||||
|
||||
To scan specific repositories within an organization, combine the `--organization` and `--repository` flags. The `--organization` flag qualifies unqualified repository names automatically:
|
||||
|
||||
```console
|
||||
prowler github --organization my-organization --repository my-repo
|
||||
```
|
||||
|
||||
This scans only `my-organization/my-repo`. Fully qualified repository names (`owner/repo-name`) are also supported alongside `--organization`:
|
||||
|
||||
```console
|
||||
prowler github --organization my-org --repository my-repo other-owner/other-repo
|
||||
```
|
||||
|
||||
In this case, `my-repo` is qualified as `my-org/my-repo`, while `other-owner/other-repo` is used as-is.
|
||||
|
||||
<Note>
|
||||
The `--repository` and `--organization` flags can be combined with any authentication method.
|
||||
</Note>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'MongoDB Atlas Authentication'
|
||||
title: 'MongoDB Atlas Authentication in Prowler'
|
||||
---
|
||||
|
||||
MongoDB Atlas provider uses [HTTP Digest Authentication with API key pairs consisting of a public key and private key](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-service).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'Oracle Cloud Infrastructure (OCI) Authentication'
|
||||
title: 'Oracle Cloud Infrastructure (OCI) Authentication in Prowler'
|
||||
---
|
||||
|
||||
This guide covers all authentication methods supported by Prowler for Oracle Cloud Infrastructure (OCI).
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
---
|
||||
title: 'OpenStack Authentication in Prowler'
|
||||
---
|
||||
|
||||
<Warning>
|
||||
Prowler currently supports **public cloud OpenStack providers** (OVH, Infomaniak, Vexxhost, etc.). Support for self-deployed OpenStack environments is not yet available and will be added in future releases.
|
||||
</Warning>
|
||||
|
||||
This guide shows how to obtain OpenStack credentials and configure Prowler to scan your OpenStack infrastructure using the recommended `clouds.yaml` authentication method.
|
||||
|
||||
## Quick Start: Getting Your OpenStack Credentials
|
||||
|
||||
<Tabs>
|
||||
<Tab title="OVH">
|
||||
### Step 1: Create an OpenStack User with Reader Role
|
||||
|
||||
Before using Prowler, create a dedicated user in your OVH Public Cloud account:
|
||||
|
||||
1. Log into the [OVH Control Panel](https://www.ovh.com/manager/)
|
||||
2. Navigate to "Public Cloud" → Select your project
|
||||
3. Click "Users & Roles" in the left sidebar
|
||||
|
||||

|
||||
|
||||
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)
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
title: 'Getting Started With OpenStack'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="5.18.0" />
|
||||
|
||||
Prowler for OpenStack allows you to audit your OpenStack cloud infrastructure for security misconfigurations, including compute instances, networking, identity and access management, storage, and more.
|
||||
|
||||
<Warning>
|
||||
Prowler currently supports **public cloud OpenStack providers** (OVH, Infomaniak, Vexxhost, etc.). Support for self-deployed OpenStack environments is not yet available, if you are interested in this feature, please [open an issue](https://github.com/prowler-cloud/prowler/issues/new) or [contact us](https://prowler.com/contact).
|
||||
</Warning>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running Prowler with the OpenStack provider, ensure you have:
|
||||
|
||||
1. An OpenStack public cloud account with at least one project
|
||||
2. Access to the Horizon dashboard or provider control panel
|
||||
3. An OpenStack user with the **Reader** role assigned to your project (see detailed instructions in the [Authentication guide](/user-guide/providers/openstack/authentication#creating-a-user-with-reader-role))
|
||||
4. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli)) or an account created in [Prowler Cloud](https://cloud.prowler.com)
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
|
||||
Run OpenStack security audits with Prowler CLI
|
||||
</Card>
|
||||
<Card title="Authentication Methods" icon="key" href="/user-guide/providers/openstack/authentication">
|
||||
Learn about OpenStack authentication options
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
### Step 1: Set Up Authentication
|
||||
|
||||
Download the `clouds.yaml` file from your OpenStack provider (see [Authentication guide](/user-guide/providers/openstack/authentication) for detailed instructions) and save it to `~/.config/openstack/clouds.yaml`:
|
||||
|
||||
```bash
|
||||
# Create the directory
|
||||
mkdir -p ~/.config/openstack
|
||||
|
||||
# Move the downloaded file
|
||||
mv ~/Downloads/clouds.yaml ~/.config/openstack/clouds.yaml
|
||||
|
||||
# Set secure permissions
|
||||
chmod 600 ~/.config/openstack/clouds.yaml
|
||||
```
|
||||
|
||||
Prowler supports multiple authentication methods:
|
||||
|
||||
**Option 1: Using clouds.yaml (Recommended)**
|
||||
|
||||
```bash
|
||||
# Default location (~/.config/openstack/clouds.yaml)
|
||||
prowler openstack --clouds-yaml-cloud openstack
|
||||
|
||||
# Custom location
|
||||
prowler openstack --clouds-yaml-file /path/to/clouds.yaml --clouds-yaml-cloud openstack
|
||||
```
|
||||
|
||||
**Option 2: Using Environment Variables**
|
||||
|
||||
```bash
|
||||
export OS_AUTH_URL=https://auth.example.com:5000/v3
|
||||
export OS_USERNAME=user-xxxxxxxxxx
|
||||
export OS_PASSWORD=your-password
|
||||
export OS_PROJECT_ID=your-project-id
|
||||
export OS_USER_DOMAIN_NAME=Default
|
||||
export OS_PROJECT_DOMAIN_NAME=Default
|
||||
export OS_IDENTITY_API_VERSION=3
|
||||
|
||||
prowler openstack
|
||||
```
|
||||
|
||||
**Option 3: Using Flags (CLI Arguments)**
|
||||
|
||||
```bash
|
||||
prowler openstack \
|
||||
--os-auth-url https://auth.example.com:5000/v3 \
|
||||
--os-username user-xxxxxxxxxx \
|
||||
--os-password your-password \
|
||||
--os-project-id your-project-id \
|
||||
--os-user-domain-name Default \
|
||||
--os-project-domain-name Default \
|
||||
--os-identity-api-version 3
|
||||
```
|
||||
|
||||
<Note>
|
||||
For detailed step-by-step instructions with screenshots, see the [OpenStack Authentication guide](/user-guide/providers/openstack/authentication).
|
||||
</Note>
|
||||
|
||||
### Step 2: Run Your First Scan
|
||||
|
||||
Run a baseline scan of your OpenStack cloud:
|
||||
|
||||
```bash
|
||||
prowler openstack --clouds-yaml-cloud openstack
|
||||
```
|
||||
|
||||
Replace `openstack` with your cloud name if you customized it in the `clouds.yaml` file (e.g., `ovh-production`).
|
||||
|
||||
Prowler will automatically discover and audit all supported OpenStack services in your project.
|
||||
|
||||
**Scan a specific OpenStack service:**
|
||||
|
||||
```bash
|
||||
# Audit only compute (Nova) resources
|
||||
prowler openstack --services compute
|
||||
|
||||
# Audit only networking (Neutron) resources
|
||||
prowler openstack --services network
|
||||
|
||||
# Audit only identity (Keystone) resources
|
||||
prowler openstack --services identity
|
||||
```
|
||||
|
||||
**Run specific security checks:**
|
||||
|
||||
```bash
|
||||
# Execute specific checks by name
|
||||
prowler openstack --checks compute_instance_public_ip_associated
|
||||
|
||||
# List all available checks
|
||||
prowler openstack --list-checks
|
||||
```
|
||||
|
||||
**Filter by check severity:**
|
||||
|
||||
```bash
|
||||
# Run only high or critical severity checks
|
||||
prowler openstack --severity critical high
|
||||
```
|
||||
|
||||
**Generate specific output formats:**
|
||||
|
||||
```bash
|
||||
# JSON only
|
||||
prowler openstack --output-modes json
|
||||
|
||||
# CSV and HTML
|
||||
prowler openstack --output-modes csv html
|
||||
|
||||
# All formats
|
||||
prowler openstack --output-modes csv json html json-asff
|
||||
|
||||
# Custom output directory
|
||||
prowler openstack --output-directory /path/to/reports/
|
||||
```
|
||||
|
||||
**Scan multiple OpenStack clouds:**
|
||||
|
||||
Configure `clouds.yaml` with multiple cloud configurations:
|
||||
|
||||
```yaml
|
||||
clouds:
|
||||
production:
|
||||
auth:
|
||||
auth_url: https://prod.example.com:5000/v3
|
||||
username: prod-user
|
||||
password: prod-password
|
||||
project_id: prod-project-id
|
||||
region_name: RegionOne
|
||||
identity_api_version: "3"
|
||||
|
||||
staging:
|
||||
auth:
|
||||
auth_url: https://staging.example.com:5000/v3
|
||||
username: staging-user
|
||||
password: staging-password
|
||||
project_id: staging-project-id
|
||||
region_name: RegionOne
|
||||
identity_api_version: "3"
|
||||
```
|
||||
|
||||
Run audits against each environment:
|
||||
|
||||
```bash
|
||||
prowler openstack --clouds-yaml-cloud production --output-directory ./reports/production/
|
||||
prowler openstack --clouds-yaml-cloud staging --output-directory ./reports/staging/
|
||||
```
|
||||
|
||||
**Use mutelist to suppress findings:**
|
||||
|
||||
Create a mutelist file to suppress known findings:
|
||||
|
||||
```yaml
|
||||
# mutelist.yaml
|
||||
Mutelist:
|
||||
Accounts:
|
||||
"*":
|
||||
Checks:
|
||||
compute_instance_public_ip_associated:
|
||||
Resources:
|
||||
- "instance-id-1"
|
||||
- "instance-id-2"
|
||||
Reason: "Public IPs required for web servers"
|
||||
```
|
||||
|
||||
Run with mutelist:
|
||||
|
||||
```bash
|
||||
prowler openstack --mutelist-file mutelist.yaml
|
||||
```
|
||||
|
||||
### Step 3: Review the Results
|
||||
|
||||
Prowler outputs findings to the console and generates reports in multiple formats.
|
||||
|
||||
By default, Prowler generates reports in the `output/` directory:
|
||||
- CSV format: `output/prowler-output-{timestamp}.csv`
|
||||
- JSON format: `output/prowler-output-{timestamp}.json`
|
||||
- HTML dashboard: `output/prowler-output-{timestamp}.html`
|
||||
|
||||
## Supported OpenStack Services
|
||||
|
||||
Prowler currently supports security checks for the following OpenStack services:
|
||||
|
||||
| Common Name | OpenStack Service | Description | Example Checks |
|
||||
|-------------|-------------------|-------------|----------------|
|
||||
| **Compute** | Nova | Virtual machine instances | Public IP associations, security group usage |
|
||||
| **Networking** | Neutron | Virtual networks and security | Security group rules, network isolation |
|
||||
| **Identity** | Keystone | Authentication and authorization | Password policies, MFA configuration |
|
||||
| **Image** | Glance | Virtual machine images | Image visibility, image encryption |
|
||||
| **Block Storage** | Cinder | Persistent block storage | Volume encryption, backup policies |
|
||||
| **Object Storage** | Swift | Object storage service | Container ACLs, public access |
|
||||
|
||||
<Note>
|
||||
Support for additional OpenStack services will be added in future releases. Check the [release notes](https://github.com/prowler-cloud/prowler/releases) for updates.
|
||||
</Note>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
If encountering authentication errors:
|
||||
|
||||
1. Verify credentials are correct:
|
||||
```bash
|
||||
# Test OpenStack CLI with the same credentials
|
||||
openstack --os-cloud openstack server list
|
||||
```
|
||||
|
||||
2. Check network connectivity to the authentication endpoint:
|
||||
```bash
|
||||
curl https://openstack.example.com:5000/v3
|
||||
```
|
||||
|
||||
3. Verify the Identity API version is v3:
|
||||
```bash
|
||||
echo $OS_IDENTITY_API_VERSION
|
||||
# Should output: 3
|
||||
```
|
||||
|
||||
For detailed troubleshooting, see the [Authentication guide](/user-guide/providers/openstack/authentication#troubleshooting).
|
||||
|
||||
### Permission Errors
|
||||
|
||||
If checks are failing due to insufficient permissions:
|
||||
|
||||
- Ensure your OpenStack user has the **Reader** role assigned to the project
|
||||
- Check role assignments in your provider's control panel or Horizon dashboard
|
||||
- Verify that your user has access to all required services (Compute, Networking, Identity, etc.)
|
||||
- Contact your OpenStack provider support if you need additional permissions
|
||||
|
||||
### Keystone/Identity Service Limitations
|
||||
|
||||
<Warning>
|
||||
Public cloud OpenStack providers (OVH, Infomaniak, Vexxhost, etc.) typically **do not expose** the Keystone/Identity service API to customers for security reasons. This means that Identity-related security checks may not be available or may return limited information.
|
||||
|
||||
This is expected behavior, not an error. This limitation explains why those checks are not currently available in Prowler.
|
||||
</Warning>
|
||||
|
||||
If you see errors related to the Identity service:
|
||||
|
||||
- This is expected behavior for public cloud providers
|
||||
- Identity-related checks will be added for self-deployed OpenStack environments in future releases
|
||||
- Focus on other available services (Compute, Networking, Storage, etc.)
|
||||
|
||||
## OpenStack Additional Resources
|
||||
|
||||
- **Supported OpenStack versions**: Stein (2019.1) and later
|
||||
- **Minimum Identity API version**: v3
|
||||
- **Tested providers**: OVH Public Cloud, OpenStack-Ansible, DevStack
|
||||
- **Cloud compatibility**: Fully compatible with standard OpenStack APIs
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 177 KiB |
@@ -14,15 +14,15 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
If the account is created without an invitation, a new tenant will be provisioned for it. However, if the account is created through an invitation, the user will join the inviter’s tenant.
|
||||
|
||||
</Note>
|
||||
## Membership
|
||||
## Organization
|
||||
|
||||
To get to User-Invitation Management we will focus on the Membership section.
|
||||
To get to User-Invitation Management we will focus on the Organization section.
|
||||
|
||||
<Note>
|
||||
**Only users that have the _Invite and Manage Users_ or _admin_ permission can access this section.**
|
||||
|
||||
</Note>
|
||||
<img src="/images/prowler-app/rbac/membership.png" alt="Membership tab" width="700" />
|
||||
<img src="/images/prowler-app/rbac/organization.png" alt="Organization tab" width="400" />
|
||||
|
||||
### Users
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -2121,6 +2121,18 @@ dash = ">=3.0.4"
|
||||
[package.extras]
|
||||
pandas = ["numpy (>=2.0.2)", "pandas (>=2.2.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "decorator"
|
||||
version = "5.2.1"
|
||||
description = "Decorators for Humans"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"},
|
||||
{file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.2.18"
|
||||
@@ -2243,6 +2255,60 @@ docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"]
|
||||
ssh = ["paramiko (>=2.4.3)"]
|
||||
websockets = ["websocket-client (>=1.3.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "dogpile-cache"
|
||||
version = "1.4.1"
|
||||
description = "A caching front-end based on the Dogpile lock."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "python_version < \"3.10\""
|
||||
files = [
|
||||
{file = "dogpile_cache-1.4.1-py3-none-any.whl", hash = "sha256:99130ce990800c8d89c26a5a8d9923cbe1b78c8a9972c2aaa0abf3d2ef2984ad"},
|
||||
{file = "dogpile_cache-1.4.1.tar.gz", hash = "sha256:e25c60e677a5e28ff86124765fbf18c53257bcd7830749cd5ba350ace2a12989"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
decorator = ">=4.0.0"
|
||||
stevedore = ">=3.0.0"
|
||||
typing_extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
bmemcached = ["python-binary-memcached"]
|
||||
memcached = ["python-memcached"]
|
||||
pifpaf = ["pifpaf (>=3.2.0)"]
|
||||
pylibmc = ["pylibmc"]
|
||||
pymemcache = ["pymemcache"]
|
||||
redis = ["redis"]
|
||||
valkey = ["valkey"]
|
||||
|
||||
[[package]]
|
||||
name = "dogpile-cache"
|
||||
version = "1.5.0"
|
||||
description = "A caching front-end based on the Dogpile lock."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.10\""
|
||||
files = [
|
||||
{file = "dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d"},
|
||||
{file = "dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
decorator = ">=4.0.0"
|
||||
stevedore = ">=3.0.0"
|
||||
typing_extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
bmemcached = ["python-binary-memcached"]
|
||||
memcached = ["python-memcached"]
|
||||
pifpaf = ["pifpaf (>=3.3.0)"]
|
||||
pylibmc = ["pylibmc"]
|
||||
pymemcache = ["pymemcache"]
|
||||
redis = ["redis"]
|
||||
valkey = ["valkey"]
|
||||
|
||||
[[package]]
|
||||
name = "dparse"
|
||||
version = "0.6.4"
|
||||
@@ -2902,6 +2968,18 @@ files = [
|
||||
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iso8601"
|
||||
version = "2.1.0"
|
||||
description = "Simple module to parse ISO 8601 dates"
|
||||
optional = false
|
||||
python-versions = ">=3.7,<4.0"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"},
|
||||
{file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isodate"
|
||||
version = "0.7.2"
|
||||
@@ -3008,7 +3086,7 @@ version = "1.33"
|
||||
description = "Apply JSON-Patches (RFC 6902)"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"},
|
||||
{file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"},
|
||||
@@ -3039,7 +3117,7 @@ version = "3.0.0"
|
||||
description = "Identify specific nodes in a JSON document (RFC 6901)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"},
|
||||
{file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"},
|
||||
@@ -3100,6 +3178,61 @@ files = [
|
||||
[package.dependencies]
|
||||
referencing = ">=0.31.0"
|
||||
|
||||
[[package]]
|
||||
name = "keystoneauth1"
|
||||
version = "5.11.1"
|
||||
description = "Authentication Library for OpenStack Identity"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "python_version < \"3.10\""
|
||||
files = [
|
||||
{file = "keystoneauth1-5.11.1-py3-none-any.whl", hash = "sha256:4525adf03b6e591f4b9b8a72c3b14f6510a04816dd5a7aca6ebaa6dfc90b69e6"},
|
||||
{file = "keystoneauth1-5.11.1.tar.gz", hash = "sha256:806f12c49b7f4b2cad3f5a460f7bdd81e4247c81b6042596a7fea8575f6591f3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
iso8601 = ">=2.0.0"
|
||||
os-service-types = ">=1.2.0"
|
||||
pbr = ">=2.0.0"
|
||||
requests = ">=2.14.2"
|
||||
stevedore = ">=1.20.0"
|
||||
typing-extensions = ">=4.12"
|
||||
|
||||
[package.extras]
|
||||
betamax = ["PyYAML (>=3.13)", "betamax (>=0.7.0)", "fixtures (>=3.0.0)"]
|
||||
kerberos = ["requests-kerberos (>=0.8.0)"]
|
||||
oauth1 = ["oauthlib (>=0.6.2)"]
|
||||
saml2 = ["lxml (>=4.2.0)"]
|
||||
test = ["PyYAML (>=3.12)", "bandit (>=1.7.6,<1.8.0)", "betamax (>=0.7.0)", "coverage (>=4.0)", "fixtures (>=3.0.0)", "flake8-docstrings (>=1.7.0,<1.8.0)", "flake8-import-order (>=0.18.2,<0.19.0)", "hacking (>=6.1.0,<6.2.0)", "lxml (>=4.2.0)", "oauthlib (>=0.6.2)", "oslo.config (>=5.2.0)", "oslo.utils (>=3.33.0)", "oslotest (>=3.2.0)", "requests-kerberos (>=0.8.0)", "requests-mock (>=1.2.0)", "stestr (>=1.0.0)", "testresources (>=2.0.0)", "testtools (>=2.2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "keystoneauth1"
|
||||
version = "5.13.0"
|
||||
description = "Authentication Library for OpenStack Identity"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.10\""
|
||||
files = [
|
||||
{file = "keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41"},
|
||||
{file = "keystoneauth1-5.13.0.tar.gz", hash = "sha256:57c9ca407207899b50d8ff1ca8abb4a4e7427461bfc1877eb8519c3989ce63ec"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
iso8601 = ">=2.0.0"
|
||||
os-service-types = ">=1.2.0"
|
||||
pbr = ">=2.0.0"
|
||||
requests = ">=2.14.2"
|
||||
stevedore = ">=1.20.0"
|
||||
typing-extensions = ">=4.12"
|
||||
|
||||
[package.extras]
|
||||
betamax = ["PyYAML (>=3.13)", "betamax (>=0.7.0)", "fixtures (>=3.0.0)"]
|
||||
kerberos = ["requests-kerberos (>=0.8.0)"]
|
||||
oauth1 = ["oauthlib (>=0.6.2)"]
|
||||
saml2 = ["lxml (>=4.2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "kubernetes"
|
||||
version = "32.0.1"
|
||||
@@ -3884,6 +4017,46 @@ files = [
|
||||
{file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netifaces"
|
||||
version = "0.11.0"
|
||||
description = "Portable network interface information."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27m-win32.whl", hash = "sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89"},
|
||||
{file = "netifaces-0.11.0-cp34-cp34m-win32.whl", hash = "sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4"},
|
||||
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4"},
|
||||
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b"},
|
||||
{file = "netifaces-0.11.0-cp35-cp35m-win32.whl", hash = "sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-win32.whl", hash = "sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-win32.whl", hash = "sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-win32.whl", hash = "sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05"},
|
||||
{file = "netifaces-0.11.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d"},
|
||||
{file = "netifaces-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff"},
|
||||
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f"},
|
||||
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1"},
|
||||
{file = "netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "networkx"
|
||||
version = "3.2.1"
|
||||
@@ -4115,6 +4288,33 @@ jsonschema-path = ">=0.3.1,<0.4.0"
|
||||
lazy-object-proxy = ">=1.7.1,<2.0.0"
|
||||
openapi-schema-validator = ">=0.6.0,<0.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "openstacksdk"
|
||||
version = "4.0.1"
|
||||
description = "An SDK for building applications to work with OpenStack"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openstacksdk-4.0.1-py3-none-any.whl", hash = "sha256:d63187a006fff7c1de1486c9e2e1073a787af402620c3c0ed0cf5291225998ac"},
|
||||
{file = "openstacksdk-4.0.1.tar.gz", hash = "sha256:19faa1d5e6a78a2c1dc06a171e65e776ba82e9df23e1d08586225dc5ade9fc63"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = ">=2.7"
|
||||
decorator = ">=4.4.1"
|
||||
"dogpile.cache" = ">=0.6.5"
|
||||
iso8601 = ">=0.1.11"
|
||||
jmespath = ">=0.9.0"
|
||||
jsonpatch = ">=1.16,<1.20 || >1.20"
|
||||
keystoneauth1 = ">=3.18.0"
|
||||
netifaces = ">=0.10.4"
|
||||
os-service-types = ">=1.7.0"
|
||||
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
|
||||
platformdirs = ">=3"
|
||||
PyYAML = ">=3.13"
|
||||
requestsexceptions = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.35.0"
|
||||
@@ -4164,6 +4364,39 @@ files = [
|
||||
opentelemetry-api = "1.35.0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "os-service-types"
|
||||
version = "1.7.0"
|
||||
description = "Python library for consuming OpenStack sevice-types-authority data"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "python_version < \"3.10\""
|
||||
files = [
|
||||
{file = "os-service-types-1.7.0.tar.gz", hash = "sha256:31800299a82239363995b91f1ebf9106ac7758542a1e4ef6dc737a5932878c6c"},
|
||||
{file = "os_service_types-1.7.0-py2.py3-none-any.whl", hash = "sha256:0505c72205690910077fb72b88f2a1f07533c8d39f2fe75b29583481764965d6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "os-service-types"
|
||||
version = "1.8.2"
|
||||
description = "Python library for consuming OpenStack sevice-types-authority data"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.10\""
|
||||
files = [
|
||||
{file = "os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e"},
|
||||
{file = "os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
|
||||
typing-extensions = ">=4.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
@@ -4293,7 +4526,7 @@ version = "6.1.1"
|
||||
description = "Python Build Reasonableness"
|
||||
optional = false
|
||||
python-versions = ">=2.6"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76"},
|
||||
{file = "pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b"},
|
||||
@@ -4308,7 +4541,7 @@ version = "4.3.8"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"},
|
||||
{file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"},
|
||||
@@ -4628,7 +4861,7 @@ description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""
|
||||
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
|
||||
files = [
|
||||
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
|
||||
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
|
||||
@@ -5360,6 +5593,18 @@ requests = ">=2.0.0"
|
||||
[package.extras]
|
||||
rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "requestsexceptions"
|
||||
version = "1.4.0"
|
||||
description = "Import exceptions from potentially bundled packages in requests."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3"},
|
||||
{file = "requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "responses"
|
||||
version = "0.25.7"
|
||||
@@ -5629,7 +5874,6 @@ 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"},
|
||||
@@ -5638,7 +5882,6 @@ 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"},
|
||||
@@ -5647,7 +5890,6 @@ 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"},
|
||||
@@ -5656,7 +5898,6 @@ 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"},
|
||||
@@ -5665,7 +5906,6 @@ 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"},
|
||||
@@ -5869,7 +6109,7 @@ version = "5.4.1"
|
||||
description = "Manage dynamic plugins for Python applications"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"},
|
||||
{file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"},
|
||||
@@ -6613,4 +6853,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">3.9.1,<3.13"
|
||||
content-hash = "adfc2da2c6e3e803f7a151b9697dbc3f461366a03e4504eb97498cbc72b2e48c"
|
||||
content-hash = "f9ff21ae57caa3ddcd27f3753c29c1b3be2966709baed52e1bbc24e7bdc33f3c"
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.18.0] (Prowler UNRELEASED)
|
||||
## [5.18.2] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `--repository` and `--organization` flags combined interaction in GitHub provider, qualifying unqualified repository names with organization [(#10001)](https://github.com/prowler-cloud/prowler/pull/10001)
|
||||
- HPACK library logging tokens in debug mode for Azure, M365, and Cloudflare providers [(#10010)](https://github.com/prowler-cloud/prowler/pull/10010)
|
||||
|
||||
---
|
||||
|
||||
## [5.18.0] (Prowler v5.18.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -13,10 +22,12 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- 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)
|
||||
- `entra_conditional_access_policy_enforce_sign_in_frequency` check for M365 provider [(#9915)](https://github.com/prowler-cloud/prowler/pull/9915)
|
||||
- `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,6 +40,16 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- 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,6 +124,7 @@ 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
|
||||
|
||||
|
||||
@@ -361,6 +362,10 @@ def prowler():
|
||||
output_options = AlibabaCloudOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
elif provider == "openstack":
|
||||
output_options = OpenStackOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
|
||||
# Run the quick inventory for the provider if available
|
||||
if hasattr(args, "quick_inventory") and args.quick_inventory:
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
{
|
||||
"Framework": "HIPAA",
|
||||
"Name": "HIPAA compliance framework for Azure",
|
||||
"Version": "",
|
||||
"Provider": "Azure",
|
||||
"Description": "The Health Insurance Portability and Accountability Act of 1996 (HIPAA) is legislation that helps US workers to retain health insurance coverage when they change or lose jobs. The legislation also seeks to encourage electronic health records to improve the efficiency and quality of the US healthcare system through improved information sharing. This framework maps HIPAA requirements to Microsoft Azure security best practices.",
|
||||
"Requirements": [
|
||||
{
|
||||
"Id": "164_308_a_1_ii_a",
|
||||
"Name": "164.308(a)(1)(ii)(A) Risk analysis",
|
||||
"Description": "Conduct an accurate and thorough assessment of the potential risks and vulnerabilities to the confidentiality, integrity, and availability of electronic protected health information held by the covered entity or business associate.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_1_ii_a",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"defender_ensure_defender_for_server_is_on",
|
||||
"defender_ensure_defender_for_app_services_is_on",
|
||||
"defender_ensure_defender_for_sql_servers_is_on",
|
||||
"defender_ensure_defender_for_storage_is_on",
|
||||
"defender_ensure_defender_for_keyvault_is_on",
|
||||
"defender_ensure_defender_for_arm_is_on",
|
||||
"defender_ensure_defender_for_dns_is_on",
|
||||
"defender_ensure_defender_for_containers_is_on",
|
||||
"defender_ensure_defender_for_cosmosdb_is_on",
|
||||
"defender_ensure_mcas_is_enabled",
|
||||
"policy_ensure_asc_enforcement_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_1_ii_b",
|
||||
"Name": "164.308(a)(1)(ii)(B) Risk Management",
|
||||
"Description": "Implement security measures sufficient to reduce risks and vulnerabilities to a reasonable and appropriate level to comply with 164.306(a): Ensure the confidentiality, integrity, and availability of all electronic protected health information the covered entity or business associate creates, receives, maintains, or transmits.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_1_ii_b",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_ensure_encryption_with_customer_managed_keys",
|
||||
"storage_infrastructure_encryption_is_enabled",
|
||||
"storage_blob_public_access_level_is_disabled",
|
||||
"storage_default_network_access_rule_is_denied",
|
||||
"storage_ensure_private_endpoints_in_storage_accounts",
|
||||
"sqlserver_tde_encryption_enabled",
|
||||
"sqlserver_tde_encrypted_with_cmk",
|
||||
"sqlserver_unrestricted_inbound_access",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"keyvault_rbac_enabled",
|
||||
"keyvault_private_endpoints",
|
||||
"vm_ensure_attached_disks_encrypted_with_cmk",
|
||||
"vm_ensure_unattached_disks_encrypted_with_cmk",
|
||||
"network_ssh_internet_access_restricted",
|
||||
"network_rdp_internet_access_restricted",
|
||||
"network_http_internet_access_restricted",
|
||||
"network_udp_internet_access_restricted",
|
||||
"iam_subscription_roles_owner_custom_not_created",
|
||||
"iam_custom_role_has_permissions_to_administer_resource_locks",
|
||||
"cosmosdb_account_firewall_use_selected_networks",
|
||||
"cosmosdb_account_use_private_endpoints",
|
||||
"aks_clusters_public_access_disabled",
|
||||
"aks_clusters_created_with_private_nodes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_1_ii_d",
|
||||
"Name": "164.308(a)(1)(ii)(D) Information system activity review",
|
||||
"Description": "Implement procedures to regularly review records of information system activity, such as audit logs, access reports, and security incident tracking reports.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_1_ii_d",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"monitor_diagnostic_setting_with_appropriate_categories",
|
||||
"monitor_diagnostic_settings_exists",
|
||||
"monitor_alert_create_policy_assignment",
|
||||
"monitor_alert_delete_policy_assignment",
|
||||
"monitor_alert_create_update_nsg",
|
||||
"monitor_alert_delete_nsg",
|
||||
"monitor_alert_create_update_security_solution",
|
||||
"monitor_alert_delete_security_solution",
|
||||
"sqlserver_auditing_enabled",
|
||||
"sqlserver_auditing_retention_90_days",
|
||||
"keyvault_logging_enabled",
|
||||
"network_watcher_enabled",
|
||||
"network_flow_log_captured_sent",
|
||||
"network_flow_log_more_than_90_days",
|
||||
"app_http_logs_enabled",
|
||||
"appinsights_ensure_is_configured"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_3_i",
|
||||
"Name": "164.308(a)(3)(i) Workforce security",
|
||||
"Description": "Implement policies and procedures to ensure that all members of its workforce have appropriate access to electronic protected health information, as provided under paragraph (a)(4) of this section, and to prevent those workforce members who do not have access under paragraph (a)(4) of this section from obtaining access to electronic protected health information.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_3_i",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_blob_public_access_level_is_disabled",
|
||||
"storage_default_network_access_rule_is_denied",
|
||||
"sqlserver_unrestricted_inbound_access",
|
||||
"network_ssh_internet_access_restricted",
|
||||
"network_rdp_internet_access_restricted",
|
||||
"network_http_internet_access_restricted",
|
||||
"iam_subscription_roles_owner_custom_not_created",
|
||||
"iam_role_user_access_admin_restricted",
|
||||
"containerregistry_not_publicly_accessible",
|
||||
"app_function_not_publicly_accessible",
|
||||
"aisearch_service_not_publicly_accessible",
|
||||
"cosmosdb_account_firewall_use_selected_networks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_3_ii_a",
|
||||
"Name": "164.308(a)(3)(ii)(A) Authorization and/or supervision",
|
||||
"Description": "Implement procedures for the authorization and/or supervision of workforce members who work with electronic protected health information or in locations where it might be accessed.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_3_ii_a",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"monitor_diagnostic_setting_with_appropriate_categories",
|
||||
"monitor_diagnostic_settings_exists",
|
||||
"sqlserver_auditing_enabled",
|
||||
"keyvault_logging_enabled",
|
||||
"entra_privileged_user_has_mfa",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_security_defaults_enabled",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_user_with_vm_access_has_mfa",
|
||||
"network_flow_log_captured_sent",
|
||||
"app_http_logs_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_3_ii_b",
|
||||
"Name": "164.308(a)(3)(ii)(B) Workforce clearance procedure",
|
||||
"Description": "Implement procedures to determine that the access of a workforce member to electronic protected health information is appropriate.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_3_ii_b",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "entra"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_subscription_roles_owner_custom_not_created",
|
||||
"iam_role_user_access_admin_restricted",
|
||||
"iam_custom_role_has_permissions_to_administer_resource_locks",
|
||||
"entra_global_admin_in_less_than_five_users",
|
||||
"entra_policy_default_users_cannot_create_security_groups",
|
||||
"entra_policy_ensure_default_user_cannot_create_apps",
|
||||
"entra_policy_guest_invite_only_for_admin_roles",
|
||||
"entra_policy_guest_users_access_restrictions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_3_ii_c",
|
||||
"Name": "164.308(a)(3)(ii)(C) Termination procedures",
|
||||
"Description": "Implement procedures for terminating access to electronic protected health information when the employment of, or other arrangement with, a workforce member ends or as required by determinations made as specified in paragraph (a)(3)(ii)(b).",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_3_ii_c",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_key_rotation_90_days",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"keyvault_rbac_key_expiration_set",
|
||||
"keyvault_rbac_secret_expiration_set",
|
||||
"keyvault_key_expiration_set_in_non_rbac",
|
||||
"keyvault_non_rbac_secret_expiration_set"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_4_i",
|
||||
"Name": "164.308(a)(4)(i) Information access management",
|
||||
"Description": "Implement policies and procedures for authorizing access to electronic protected health information that are consistent with the applicable requirements of subpart E of this part.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_4_i",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_subscription_roles_owner_custom_not_created",
|
||||
"iam_role_user_access_admin_restricted",
|
||||
"iam_custom_role_has_permissions_to_administer_resource_locks",
|
||||
"keyvault_rbac_enabled",
|
||||
"entra_global_admin_in_less_than_five_users",
|
||||
"entra_policy_restricts_user_consent_for_apps",
|
||||
"entra_policy_user_consent_for_verified_apps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_4_ii_a",
|
||||
"Name": "164.308(a)(4)(ii)(A) Isolating health care clearinghouse functions",
|
||||
"Description": "If a health care clearinghouse is part of a larger organization, the clearinghouse must implement policies and procedures that protect the electronic protected health information of the clearinghouse from unauthorized access by the larger organization.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_4_ii_a",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_ensure_encryption_with_customer_managed_keys",
|
||||
"storage_infrastructure_encryption_is_enabled",
|
||||
"storage_ensure_private_endpoints_in_storage_accounts",
|
||||
"storage_default_network_access_rule_is_denied",
|
||||
"sqlserver_tde_encryption_enabled",
|
||||
"sqlserver_tde_encrypted_with_cmk",
|
||||
"sqlserver_auditing_enabled",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"keyvault_logging_enabled",
|
||||
"keyvault_private_endpoints",
|
||||
"vm_ensure_attached_disks_encrypted_with_cmk",
|
||||
"vm_backup_enabled",
|
||||
"cosmosdb_account_use_private_endpoints",
|
||||
"databricks_workspace_cmk_encryption_enabled",
|
||||
"databricks_workspace_vnet_injection_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_4_ii_b",
|
||||
"Name": "164.308(a)(4)(ii)(B) Access authorization",
|
||||
"Description": "Implement policies and procedures for granting access to electronic protected health information, as one illustrative example, through access to a workstation, transaction, program, process, or other mechanism.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_4_ii_b",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_subscription_roles_owner_custom_not_created",
|
||||
"iam_role_user_access_admin_restricted",
|
||||
"iam_custom_role_has_permissions_to_administer_resource_locks",
|
||||
"keyvault_rbac_enabled",
|
||||
"aks_cluster_rbac_enabled",
|
||||
"cosmosdb_account_use_aad_and_rbac",
|
||||
"sqlserver_azuread_administrator_enabled",
|
||||
"entra_global_admin_in_less_than_five_users"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_4_ii_c",
|
||||
"Name": "164.308(a)(4)(ii)(C) Access establishment and modification",
|
||||
"Description": "Implement policies and procedures that, based upon the covered entity's or the business associate's access authorization policies, establish, document, review, and modify a user's right of access to a workstation, transaction, program, or process.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_4_ii_c",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_subscription_roles_owner_custom_not_created",
|
||||
"iam_role_user_access_admin_restricted",
|
||||
"storage_key_rotation_90_days",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"keyvault_rbac_key_expiration_set",
|
||||
"keyvault_rbac_secret_expiration_set",
|
||||
"entra_global_admin_in_less_than_five_users",
|
||||
"entra_policy_default_users_cannot_create_security_groups",
|
||||
"entra_policy_ensure_default_user_cannot_create_apps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_5_ii_b",
|
||||
"Name": "164.308(a)(5)(ii)(B) Protection from malicious software",
|
||||
"Description": "Procedures for guarding against, detecting, and reporting malicious software.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_5_ii_b",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"defender_ensure_defender_for_server_is_on",
|
||||
"defender_ensure_wdatp_is_enabled",
|
||||
"defender_assessments_vm_endpoint_protection_installed",
|
||||
"defender_ensure_system_updates_are_applied",
|
||||
"defender_container_images_scan_enabled",
|
||||
"defender_container_images_resolved_vulnerabilities"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_5_ii_c",
|
||||
"Name": "164.308(a)(5)(ii)(C) Log-in monitoring",
|
||||
"Description": "Procedures for monitoring log-in attempts and reporting discrepancies.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_5_ii_c",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"defender_ensure_defender_for_server_is_on",
|
||||
"defender_ensure_mcas_is_enabled",
|
||||
"monitor_diagnostic_setting_with_appropriate_categories",
|
||||
"entra_security_defaults_enabled",
|
||||
"sqlserver_auditing_enabled",
|
||||
"keyvault_logging_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_5_ii_d",
|
||||
"Name": "164.308(a)(5)(ii)(D) Password management",
|
||||
"Description": "Procedures for creating, changing, and safeguarding passwords.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_5_ii_d",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "entra"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"entra_security_defaults_enabled",
|
||||
"entra_privileged_user_has_mfa",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"storage_key_rotation_90_days",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"keyvault_rbac_key_expiration_set",
|
||||
"keyvault_rbac_secret_expiration_set"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_6_i",
|
||||
"Name": "164.308(a)(6)(i) Security incident procedures",
|
||||
"Description": "Implement policies and procedures to address security incidents.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_6_i",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"monitor_alert_create_update_nsg",
|
||||
"monitor_alert_delete_nsg",
|
||||
"monitor_alert_create_update_security_solution",
|
||||
"monitor_alert_delete_security_solution",
|
||||
"monitor_alert_service_health_exists",
|
||||
"defender_ensure_defender_for_server_is_on",
|
||||
"defender_ensure_notify_alerts_severity_is_high",
|
||||
"defender_ensure_notify_emails_to_owners",
|
||||
"defender_additional_email_configured_with_a_security_contact",
|
||||
"defender_attack_path_notifications_properly_configured"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_6_ii",
|
||||
"Name": "164.308(a)(6)(ii) Response and reporting",
|
||||
"Description": "Identify and respond to suspected or known security incidents; mitigate, to the extent practicable, harmful effects of security incidents that are known to the covered entity or business associate; and document security incidents and their outcomes.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_6_ii",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"monitor_diagnostic_setting_with_appropriate_categories",
|
||||
"monitor_diagnostic_settings_exists",
|
||||
"monitor_alert_create_update_nsg",
|
||||
"monitor_alert_delete_nsg",
|
||||
"defender_ensure_defender_for_server_is_on",
|
||||
"defender_ensure_notify_alerts_severity_is_high",
|
||||
"defender_ensure_notify_emails_to_owners",
|
||||
"defender_additional_email_configured_with_a_security_contact",
|
||||
"sqlserver_auditing_enabled",
|
||||
"keyvault_logging_enabled",
|
||||
"network_flow_log_captured_sent",
|
||||
"app_http_logs_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_7_i",
|
||||
"Name": "164.308(a)(7)(i) Contingency plan",
|
||||
"Description": "Establish (and implement as needed) policies and procedures for responding to an emergency or other occurrence (for example, fire, vandalism, system failure, and natural disaster) that damages systems that contain electronic protected health information.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_7_i",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"vm_backup_enabled",
|
||||
"vm_sufficient_daily_backup_retention_period",
|
||||
"storage_blob_versioning_is_enabled",
|
||||
"storage_ensure_soft_delete_is_enabled",
|
||||
"storage_ensure_file_shares_soft_delete_is_enabled",
|
||||
"storage_geo_redundant_enabled",
|
||||
"keyvault_recoverable",
|
||||
"sqlserver_auditing_retention_90_days"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_7_ii_a",
|
||||
"Name": "164.308(a)(7)(ii)(A) Data backup plan",
|
||||
"Description": "Establish and implement procedures to create and maintain retrievable exact copies of electronic protected health information.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_7_ii_a",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"vm_backup_enabled",
|
||||
"vm_sufficient_daily_backup_retention_period",
|
||||
"storage_blob_versioning_is_enabled",
|
||||
"storage_ensure_soft_delete_is_enabled",
|
||||
"storage_ensure_file_shares_soft_delete_is_enabled",
|
||||
"storage_geo_redundant_enabled",
|
||||
"keyvault_recoverable",
|
||||
"sqlserver_auditing_retention_90_days",
|
||||
"postgresql_flexible_server_log_retention_days_greater_3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_7_ii_b",
|
||||
"Name": "164.308(a)(7)(ii)(B) Disaster recovery plan",
|
||||
"Description": "Establish (and implement as needed) procedures to restore any loss of data.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_7_ii_b",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"vm_backup_enabled",
|
||||
"vm_sufficient_daily_backup_retention_period",
|
||||
"storage_blob_versioning_is_enabled",
|
||||
"storage_ensure_soft_delete_is_enabled",
|
||||
"storage_geo_redundant_enabled",
|
||||
"keyvault_recoverable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_7_ii_c",
|
||||
"Name": "164.308(a)(7)(ii)(C) Emergency mode operation plan",
|
||||
"Description": "Establish (and implement as needed) procedures to enable continuation of critical business processes for protection of the security of electronic protected health information while operating in emergency mode.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_7_ii_c",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"vm_backup_enabled",
|
||||
"vm_sufficient_daily_backup_retention_period",
|
||||
"storage_blob_versioning_is_enabled",
|
||||
"storage_ensure_soft_delete_is_enabled",
|
||||
"storage_geo_redundant_enabled",
|
||||
"keyvault_recoverable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_308_a_8",
|
||||
"Name": "164.308(a)(8) Evaluation",
|
||||
"Description": "Perform a periodic technical and nontechnical evaluation, based initially upon the standards implemented under this rule and subsequently, in response to environmental or operational changes affecting the security of electronic protected health information, that establishes the extent to which an entity's security policies and procedures meet the requirements of this subpart.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_308_a_8",
|
||||
"Section": "164.308 Administrative Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"defender_ensure_defender_for_server_is_on",
|
||||
"defender_ensure_mcas_is_enabled",
|
||||
"sqlserver_vulnerability_assessment_enabled",
|
||||
"sqlserver_va_periodic_recurring_scans_enabled",
|
||||
"sqlserver_va_scan_reports_configured",
|
||||
"sqlserver_va_emails_notifications_admins_enabled",
|
||||
"policy_ensure_asc_enforcement_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_310_a_1",
|
||||
"Name": "164.310(a)(1) Facility access controls",
|
||||
"Description": "Implement policies and procedures to limit physical access to its electronic information systems and the facility or facilities in which they are housed, while ensuring that properly authorized access is allowed.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_310_a_1",
|
||||
"Section": "164.310 Physical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"network_ssh_internet_access_restricted",
|
||||
"network_rdp_internet_access_restricted",
|
||||
"network_http_internet_access_restricted",
|
||||
"network_bastion_host_exists",
|
||||
"vm_jit_access_enabled",
|
||||
"aks_clusters_public_access_disabled",
|
||||
"aks_clusters_created_with_private_nodes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_310_d_1",
|
||||
"Name": "164.310(d)(1) Device and media controls",
|
||||
"Description": "Implement policies and procedures that govern the receipt and removal of hardware and electronic media that contain electronic protected health information into and out of a facility, and the movement of these items within the facility.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_310_d_1",
|
||||
"Section": "164.310 Physical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_ensure_encryption_with_customer_managed_keys",
|
||||
"storage_infrastructure_encryption_is_enabled",
|
||||
"vm_ensure_attached_disks_encrypted_with_cmk",
|
||||
"vm_ensure_unattached_disks_encrypted_with_cmk",
|
||||
"vm_ensure_using_managed_disks",
|
||||
"sqlserver_tde_encryption_enabled",
|
||||
"databricks_workspace_cmk_encryption_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_a_1",
|
||||
"Name": "164.312(a)(1) Access control",
|
||||
"Description": "Implement technical policies and procedures for electronic information systems that maintain electronic protected health information to allow access only to those persons or software programs that have been granted access rights as specified in 164.308(a)(4).",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_a_1",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_blob_public_access_level_is_disabled",
|
||||
"storage_default_network_access_rule_is_denied",
|
||||
"storage_ensure_private_endpoints_in_storage_accounts",
|
||||
"sqlserver_unrestricted_inbound_access",
|
||||
"network_ssh_internet_access_restricted",
|
||||
"network_rdp_internet_access_restricted",
|
||||
"network_http_internet_access_restricted",
|
||||
"iam_subscription_roles_owner_custom_not_created",
|
||||
"iam_role_user_access_admin_restricted",
|
||||
"entra_privileged_user_has_mfa",
|
||||
"containerregistry_not_publicly_accessible",
|
||||
"app_function_not_publicly_accessible",
|
||||
"aisearch_service_not_publicly_accessible",
|
||||
"cosmosdb_account_firewall_use_selected_networks",
|
||||
"cosmosdb_account_use_private_endpoints",
|
||||
"aks_clusters_public_access_disabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_a_2_i",
|
||||
"Name": "164.312(a)(2)(i) Unique user identification",
|
||||
"Description": "Assign a unique name and/or number for identifying and tracking user identity.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_a_2_i",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"sqlserver_auditing_enabled",
|
||||
"sqlserver_azuread_administrator_enabled",
|
||||
"entra_security_defaults_enabled",
|
||||
"storage_default_to_entra_authorization_enabled",
|
||||
"cosmosdb_account_use_aad_and_rbac",
|
||||
"postgresql_flexible_server_entra_id_authentication_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_a_2_ii",
|
||||
"Name": "164.312(a)(2)(ii) Emergency access procedure",
|
||||
"Description": "Establish (and implement as needed) procedures for obtaining necessary electronic protected health information during an emergency.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_a_2_ii",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"vm_backup_enabled",
|
||||
"vm_sufficient_daily_backup_retention_period",
|
||||
"storage_blob_versioning_is_enabled",
|
||||
"storage_ensure_soft_delete_is_enabled",
|
||||
"storage_geo_redundant_enabled",
|
||||
"keyvault_recoverable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_a_2_iv",
|
||||
"Name": "164.312(a)(2)(iv) Encryption and decryption",
|
||||
"Description": "Implement a mechanism to encrypt and decrypt electronic protected health information.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_a_2_iv",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_ensure_encryption_with_customer_managed_keys",
|
||||
"storage_infrastructure_encryption_is_enabled",
|
||||
"storage_secure_transfer_required_is_enabled",
|
||||
"sqlserver_tde_encryption_enabled",
|
||||
"sqlserver_tde_encrypted_with_cmk",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"vm_ensure_attached_disks_encrypted_with_cmk",
|
||||
"vm_ensure_unattached_disks_encrypted_with_cmk",
|
||||
"databricks_workspace_cmk_encryption_enabled",
|
||||
"monitor_storage_account_with_activity_logs_cmk_encrypted"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_b",
|
||||
"Name": "164.312(b) Audit controls",
|
||||
"Description": "Implement hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_b",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"monitor_diagnostic_setting_with_appropriate_categories",
|
||||
"monitor_diagnostic_settings_exists",
|
||||
"monitor_alert_create_policy_assignment",
|
||||
"monitor_alert_delete_policy_assignment",
|
||||
"monitor_alert_create_update_nsg",
|
||||
"monitor_alert_delete_nsg",
|
||||
"monitor_alert_create_update_sqlserver_fr",
|
||||
"monitor_alert_delete_sqlserver_fr",
|
||||
"sqlserver_auditing_enabled",
|
||||
"sqlserver_auditing_retention_90_days",
|
||||
"keyvault_logging_enabled",
|
||||
"network_watcher_enabled",
|
||||
"network_flow_log_captured_sent",
|
||||
"network_flow_log_more_than_90_days",
|
||||
"app_http_logs_enabled",
|
||||
"appinsights_ensure_is_configured",
|
||||
"postgresql_flexible_server_log_checkpoints_on",
|
||||
"postgresql_flexible_server_log_connections_on",
|
||||
"postgresql_flexible_server_log_disconnections_on",
|
||||
"mysql_flexible_server_audit_log_enabled",
|
||||
"mysql_flexible_server_audit_log_connection_activated"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_c_1",
|
||||
"Name": "164.312(c)(1) Integrity",
|
||||
"Description": "Implement policies and procedures to protect electronic protected health information from improper alteration or destruction.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_c_1",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_ensure_encryption_with_customer_managed_keys",
|
||||
"storage_blob_versioning_is_enabled",
|
||||
"storage_secure_transfer_required_is_enabled",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"keyvault_recoverable",
|
||||
"sqlserver_tde_encryption_enabled",
|
||||
"vm_ensure_attached_disks_encrypted_with_cmk"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_c_2",
|
||||
"Name": "164.312(c)(2) Mechanism to authenticate electronic protected health information",
|
||||
"Description": "Implement electronic mechanisms to corroborate that electronic protected health information has not been altered or destroyed in an unauthorized manner.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_c_2",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_ensure_encryption_with_customer_managed_keys",
|
||||
"storage_blob_versioning_is_enabled",
|
||||
"storage_secure_transfer_required_is_enabled",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"keyvault_logging_enabled",
|
||||
"sqlserver_auditing_enabled",
|
||||
"network_flow_log_captured_sent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_d",
|
||||
"Name": "164.312(d) Person or entity authentication",
|
||||
"Description": "Implement procedures to verify that a person or entity seeking access to electronic protected health information is the one claimed.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_d",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "entra"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"entra_security_defaults_enabled",
|
||||
"entra_privileged_user_has_mfa",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_user_with_vm_access_has_mfa",
|
||||
"entra_trusted_named_locations_exists",
|
||||
"sqlserver_azuread_administrator_enabled",
|
||||
"postgresql_flexible_server_entra_id_authentication_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_e_1",
|
||||
"Name": "164.312(e)(1) Transmission security",
|
||||
"Description": "Implement technical security measures to guard against unauthorized access to electronic protected health information that is being transmitted over an electronic communications network.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_e_1",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_secure_transfer_required_is_enabled",
|
||||
"storage_ensure_minimum_tls_version_12",
|
||||
"sqlserver_recommended_minimal_tls_version",
|
||||
"app_minimum_tls_version_12",
|
||||
"app_ensure_http_is_redirected_to_https",
|
||||
"app_ensure_using_http20",
|
||||
"app_function_ftps_deployment_disabled",
|
||||
"app_ftp_deployment_disabled",
|
||||
"network_ssh_internet_access_restricted",
|
||||
"network_rdp_internet_access_restricted",
|
||||
"mysql_flexible_server_minimum_tls_version_12",
|
||||
"mysql_flexible_server_ssl_connection_enabled",
|
||||
"postgresql_flexible_server_enforce_ssl_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_e_2_i",
|
||||
"Name": "164.312(e)(2)(i) Integrity controls",
|
||||
"Description": "Implement security measures to ensure that electronically transmitted electronic protected health information is not improperly modified without detection until disposed of.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_e_2_i",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"monitor_diagnostic_setting_with_appropriate_categories",
|
||||
"storage_secure_transfer_required_is_enabled",
|
||||
"storage_ensure_minimum_tls_version_12",
|
||||
"storage_blob_versioning_is_enabled",
|
||||
"defender_ensure_defender_for_server_is_on",
|
||||
"sqlserver_auditing_enabled",
|
||||
"keyvault_logging_enabled",
|
||||
"network_flow_log_captured_sent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "164_312_e_2_ii",
|
||||
"Name": "164.312(e)(2)(ii) Encryption",
|
||||
"Description": "Implement a mechanism to encrypt electronic protected health information whenever deemed appropriate.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "164_312_e_2_ii",
|
||||
"Section": "164.312 Technical Safeguards",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"storage_ensure_encryption_with_customer_managed_keys",
|
||||
"storage_infrastructure_encryption_is_enabled",
|
||||
"storage_secure_transfer_required_is_enabled",
|
||||
"storage_ensure_minimum_tls_version_12",
|
||||
"sqlserver_tde_encryption_enabled",
|
||||
"sqlserver_tde_encrypted_with_cmk",
|
||||
"sqlserver_recommended_minimal_tls_version",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"vm_ensure_attached_disks_encrypted_with_cmk",
|
||||
"vm_ensure_unattached_disks_encrypted_with_cmk",
|
||||
"app_minimum_tls_version_12",
|
||||
"app_ensure_http_is_redirected_to_https",
|
||||
"mysql_flexible_server_minimum_tls_version_12",
|
||||
"mysql_flexible_server_ssl_connection_enabled",
|
||||
"postgresql_flexible_server_enforce_ssl_enabled",
|
||||
"databricks_workspace_cmk_encryption_enabled"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -307,7 +307,6 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_privileged_user_has_mfa",
|
||||
@@ -1103,11 +1102,10 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"app_minimum_tls_version_12",
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_app",
|
||||
"entra_non_privileged_user_has_mfa entra_privileged_user_has_mfa",
|
||||
"entra_user_with_vm_access_has_mfa",
|
||||
"app_minimum_tls_version_12",
|
||||
"sqlserver_tde_encryption_enabled",
|
||||
"storage_ensure_encryption_with_customer_managed_keys",
|
||||
"storage_infrastructure_encryption_is_enabled"
|
||||
|
||||
@@ -212,7 +212,6 @@
|
||||
"Description": "Adversaries may obtain and abuse credentials of existing accounts as a means of gaining Initial Access, Persistence, Privilege Escalation, or Defense Evasion. Compromised credentials may be used to bypass access controls placed on various resources on systems within the network and may even be used for persistent access to remote systems and externally available services, such as VPNs, Outlook Web Access, network devices, and remote desktop.[1] Compromised credentials may also grant an adversary increased privilege to specific systems or access to restricted areas of the network. Adversaries may choose not to use malware or tools in conjunction with the legitimate access those credentials provide to make it harder to detect their presence.",
|
||||
"TechniqueURL": "https://attack.mitre.org/techniques/T1078/",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_global_admin_in_less_than_five_users",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
@@ -805,7 +804,6 @@
|
||||
"Description": "Adversaries may modify authentication mechanisms and processes to access user credentials or enable otherwise unwarranted access to accounts. The authentication process is handled by mechanisms, such as the Local Security Authentication Server (LSASS) process and the Security Accounts Manager (SAM) on Windows, pluggable authentication modules (PAM) on Unix-based systems, and authorization plugins on MacOS systems, responsible for gathering, storing, and validating credentials. By modifying an authentication process, an adversary may be able to authenticate to a service or system without using Valid Accounts.",
|
||||
"TechniqueURL": "https://attack.mitre.org/techniques/T1556/",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_global_admin_in_less_than_five_users",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
@@ -997,7 +995,6 @@
|
||||
"Description": "Adversaries may use alternate authentication material, such as password hashes, Kerberos tickets, and application access tokens, in order to move laterally within an environment and bypass normal system access controls.",
|
||||
"TechniqueURL": "https://attack.mitre.org/techniques/T1550/",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_global_admin_in_less_than_five_users",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_policy_default_users_cannot_create_security_groups",
|
||||
@@ -1190,7 +1187,6 @@
|
||||
"Description": "Adversaries may forge credential materials that can be used to gain access to web applications or Internet services. Web applications and services (hosted in cloud SaaS environments or on-premise servers) often use session cookies, tokens, or other materials to authenticate and authorize user access.",
|
||||
"TechniqueURL": "https://attack.mitre.org/techniques/T1606/",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_policy_default_users_cannot_create_security_groups",
|
||||
"entra_policy_ensure_default_user_cannot_create_apps",
|
||||
"entra_policy_ensure_default_user_cannot_create_tenants",
|
||||
|
||||
@@ -1603,7 +1603,6 @@
|
||||
"Id": "11.3.2.a",
|
||||
"Description": "establish strong identification, authentication such as multi-factor authentication, and authorisation procedures for privileged accounts and system administration accounts;",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_privileged_user_has_mfa",
|
||||
@@ -1691,11 +1690,10 @@
|
||||
"Id": "11.4.2.c",
|
||||
"Description": "protect access to system administration systems through authentication and encryption.",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_privileged_user_has_mfa",
|
||||
"entra_trusted_named_locations_exists",
|
||||
"entra_user_with_vm_access_has_mfa"
|
||||
"entra_user_with_vm_access_has_mfa",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_privileged_user_has_mfa"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
@@ -1764,7 +1762,6 @@
|
||||
"Id": "11.6.2.a",
|
||||
"Description": "ensure the strength of authentication is appropriate to the classification of the asset to be accessed;",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_privileged_user_has_mfa",
|
||||
@@ -1797,7 +1794,6 @@
|
||||
"Id": "11.7.2",
|
||||
"Description": "The relevant entities shall ensure that the strength of authentication is appropriate for the classification of the asset to be accessed.",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_privileged_user_has_mfa",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"Id": "1.1.3",
|
||||
"Description": "Ensure Multi-factor Authentication is Required for Windows Azure Service Management API",
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api"
|
||||
],
|
||||
"Attributes": [
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_global_admin_in_less_than_five_users",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
@@ -241,7 +240,6 @@
|
||||
"aks_clusters_public_access_disabled",
|
||||
"app_function_not_publicly_accessible",
|
||||
"containerregistry_not_publicly_accessible",
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"network_public_ip_shodan",
|
||||
"storage_blob_public_access_level_is_disabled"
|
||||
]
|
||||
@@ -282,7 +280,6 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_privileged_user_has_mfa",
|
||||
"entra_user_with_vm_access_has_mfa"
|
||||
@@ -301,15 +298,14 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"app_minimum_tls_version_12",
|
||||
"entra_conditional_access_policy_enforce_sign_in_frequency",
|
||||
"mysql_flexible_server_minimum_tls_version_12",
|
||||
"mysql_flexible_server_ssl_connection_enabled",
|
||||
"network_http_internet_access_restricted",
|
||||
"network_rdp_internet_access_restricted",
|
||||
"network_ssh_internet_access_restricted",
|
||||
"network_udp_internet_access_restricted",
|
||||
"mysql_flexible_server_ssl_connection_enabled",
|
||||
"postgresql_flexible_server_enforce_ssl_enabled",
|
||||
"app_minimum_tls_version_12",
|
||||
"mysql_flexible_server_minimum_tls_version_12",
|
||||
"sqlserver_recommended_minimal_tls_version",
|
||||
"storage_ensure_minimum_tls_version_12"
|
||||
]
|
||||
|
||||
@@ -38,7 +38,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.18.0"
|
||||
prowler_version = "5.18.2"
|
||||
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,6 +62,7 @@ class Provider(str, Enum):
|
||||
MONGODBATLAS = "mongodbatlas"
|
||||
ORACLECLOUD = "oraclecloud"
|
||||
ALIBABACLOUD = "alibabacloud"
|
||||
OPENSTACK = "openstack"
|
||||
|
||||
|
||||
# Compliance
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
### Project ID, Check and/or Region can be * to apply for all the cases.
|
||||
### Resources and tags are lists that can have either Regex or Keywords.
|
||||
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
|
||||
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
|
||||
### For each check you can except Project IDs, Regions, Resources and/or Tags.
|
||||
########################### MUTELIST EXAMPLE ###########################
|
||||
Mutelist:
|
||||
Accounts:
|
||||
"example-project-id": # Your OpenStack project ID
|
||||
Checks:
|
||||
"compute_instance_security_groups_attached":
|
||||
Regions:
|
||||
- "EU-WEST-PAR"
|
||||
Resources:
|
||||
- "prowler-test-fail" # Mute by instance name
|
||||
- "example-instance-id" # Mute by instance ID
|
||||
Description: "Mute prowler-test-fail instance in compute_instance_security_groups_attached check"
|
||||
"compute_*":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "test-*" # Mute all resources starting with "test-"
|
||||
Description: "Mute all test instances for all compute checks"
|
||||
"*":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "dev-instance"
|
||||
Tags:
|
||||
- "environment=dev" # Mute resources with environment=dev tag
|
||||
- "testing=true"
|
||||
Description: "Mute all resources with specific tags"
|
||||
|
||||
"*": # Apply to all projects
|
||||
Checks:
|
||||
"compute_instance_security_groups_attached":
|
||||
Regions:
|
||||
- "EU-WEST-PAR"
|
||||
Resources:
|
||||
- "legacy-.*" # Regex: mute all instances starting with "legacy-"
|
||||
Description: "Mute legacy instances in EU-WEST-PAR region"
|
||||
"*":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "*"
|
||||
Tags:
|
||||
- "prowler-ignore=true" # Mute any resource with this tag across all checks
|
||||
Description: "Global mute for resources tagged with prowler-ignore=true"
|
||||
"identity_password_policy_enabled":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "*"
|
||||
Exceptions:
|
||||
Accounts:
|
||||
- "production-project-id"
|
||||
Regions:
|
||||
- "US-EAST-1"
|
||||
Description: "Mute identity_password_policy_enabled everywhere EXCEPT in production-project-id in US-EAST-1"
|
||||
@@ -687,6 +687,10 @@ def execute(
|
||||
is_finding_muted_args["account_id"] = (
|
||||
global_provider.identity.account_id
|
||||
)
|
||||
elif global_provider.type == "openstack":
|
||||
is_finding_muted_args["project_id"] = (
|
||||
global_provider.identity.project_id
|
||||
)
|
||||
for finding in check_findings:
|
||||
if global_provider.type == "cloudflare":
|
||||
is_finding_muted_args["account_id"] = finding.account_id
|
||||
|
||||
@@ -913,6 +913,25 @@ class CheckReportNHN(Check_Report):
|
||||
self.location = getattr(resource, "location", "kr1")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckReportOpenStack(Check_Report):
|
||||
"""Contains the OpenStack Check's finding information."""
|
||||
|
||||
resource_name: str
|
||||
resource_id: str
|
||||
project_id: str
|
||||
region: str
|
||||
|
||||
def __init__(self, metadata: Dict, resource: Any) -> None:
|
||||
super().__init__(metadata, resource)
|
||||
self.resource_name = getattr(
|
||||
resource, "name", getattr(resource, "resource_name", "default")
|
||||
)
|
||||
self.resource_id = getattr(resource, "id", getattr(resource, "resource_id", ""))
|
||||
self.project_id = getattr(resource, "project_id", "")
|
||||
self.region = getattr(resource, "region", "global")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckReportMongoDBAtlas(Check_Report):
|
||||
"""Contains the MongoDB Atlas Check's finding information."""
|
||||
|
||||
@@ -27,10 +27,10 @@ class ProwlerArgumentParser:
|
||||
self.parser = argparse.ArgumentParser(
|
||||
prog="prowler",
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,dashboard,iac} ...",
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac} ...",
|
||||
epilog="""
|
||||
Available Cloud Providers:
|
||||
{aws,azure,gcp,kubernetes,m365,github,iac,llm,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare}
|
||||
{aws,azure,gcp,kubernetes,m365,github,iac,llm,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack}
|
||||
aws AWS Provider
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
@@ -39,6 +39,7 @@ 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,6 +366,20 @@ 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,6 +1160,78 @@ class HTML(Output):
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_openstack_assessment_summary(provider: Provider) -> str:
|
||||
"""
|
||||
get_openstack_assessment_summary gets the HTML assessment summary for the OpenStack provider
|
||||
|
||||
Args:
|
||||
provider (Provider): the OpenStack provider object
|
||||
|
||||
Returns:
|
||||
str: HTML assessment summary for the OpenStack provider
|
||||
"""
|
||||
try:
|
||||
project_id = getattr(provider.identity, "project_id", "unknown")
|
||||
project_name = getattr(provider.identity, "project_name", "")
|
||||
region_name = getattr(provider.identity, "region_name", "unknown")
|
||||
username = getattr(provider.identity, "username", "unknown")
|
||||
user_id = getattr(provider.identity, "user_id", "")
|
||||
|
||||
project_name_item = (
|
||||
f"""
|
||||
<li class="list-group-item">
|
||||
<b>Project Name:</b> {project_name}
|
||||
</li>"""
|
||||
if project_name
|
||||
else ""
|
||||
)
|
||||
|
||||
user_id_item = (
|
||||
f"""
|
||||
<li class="list-group-item">
|
||||
<b>User ID:</b> {user_id}
|
||||
</li>"""
|
||||
if user_id
|
||||
else ""
|
||||
)
|
||||
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
OpenStack Assessment Summary
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Project ID:</b> {project_id}
|
||||
</li>
|
||||
{project_name_item}
|
||||
<li class="list-group-item">
|
||||
<b>Region:</b> {region_name}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
OpenStack Credentials
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Username:</b> {username}
|
||||
</li>
|
||||
{user_id_item}
|
||||
</ul>
|
||||
</div>
|
||||
</div>"""
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_assessment_summary(provider: Provider) -> str:
|
||||
"""
|
||||
|
||||
@@ -1875,12 +1875,12 @@ class Jira:
|
||||
summary_parts.append(finding.resource_uid)
|
||||
|
||||
summary = " - ".join(summary_parts[1:])
|
||||
summary = f"{summary_parts[0]} {summary}"
|
||||
summary = f"{summary_parts[0]} {summary}"[:255]
|
||||
|
||||
payload = {
|
||||
"fields": {
|
||||
"project": {"key": project_key},
|
||||
"summary": f"[Prowler] {finding.metadata.Severity.value.upper()} - {finding.metadata.CheckID} - {finding.resource_uid}",
|
||||
"summary": summary,
|
||||
"description": adf_description,
|
||||
"issuetype": {"name": issue_type},
|
||||
"customfield_10148": {"value": "SDK"},
|
||||
@@ -2081,7 +2081,7 @@ class Jira:
|
||||
if resource_uid:
|
||||
summary_parts.append(resource_uid)
|
||||
summary = " - ".join(summary_parts[1:])
|
||||
summary = f"{summary_parts[0]} {summary}"
|
||||
summary = f"{summary_parts[0]} {summary}"[:255]
|
||||
|
||||
payload = {
|
||||
"fields": {
|
||||
|
||||
@@ -32,6 +32,8 @@ def stdout_report(finding, color, verbose, status, fix):
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "alibabacloud":
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "openstack":
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "cloudflare":
|
||||
details = finding.zone_name
|
||||
|
||||
|
||||
@@ -86,6 +86,13 @@ 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):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from argparse import ArgumentTypeError
|
||||
@@ -217,6 +218,9 @@ class AzureProvider(Provider):
|
||||
"""
|
||||
logger.info("Setting Azure provider ...")
|
||||
|
||||
# Mute HPACK library logs to prevent token leakage in debug mode
|
||||
logging.getLogger("hpack").setLevel(logging.CRITICAL)
|
||||
|
||||
logger.info("Checking if any credentials mode is set ...")
|
||||
|
||||
# Validate the authentication arguments
|
||||
|
||||
@@ -365,43 +365,6 @@ class Entra(AzureService):
|
||||
else:
|
||||
block_access_controls.append(str(access_control))
|
||||
|
||||
# Extract session controls (sign-in frequency)
|
||||
sign_in_frequency = None
|
||||
session_controls = getattr(policy, "session_controls", None)
|
||||
if session_controls:
|
||||
sif = getattr(session_controls, "sign_in_frequency", None)
|
||||
if sif:
|
||||
sign_in_frequency = SignInFrequencySessionControl(
|
||||
is_enabled=getattr(sif, "is_enabled", False),
|
||||
frequency_interval=(
|
||||
str(getattr(sif, "frequency_interval", None))
|
||||
if getattr(sif, "frequency_interval", None)
|
||||
else None
|
||||
),
|
||||
type=(
|
||||
str(getattr(sif, "type", None))
|
||||
if getattr(sif, "type", None)
|
||||
else None
|
||||
),
|
||||
value=getattr(sif, "value", None),
|
||||
)
|
||||
|
||||
# Extract device filter
|
||||
device_filter = None
|
||||
if conditions:
|
||||
devices = getattr(conditions, "devices", None)
|
||||
if devices:
|
||||
df = getattr(devices, "device_filter", None)
|
||||
if df:
|
||||
device_filter = DeviceFilter(
|
||||
mode=(
|
||||
str(getattr(df, "mode", None))
|
||||
if getattr(df, "mode", None)
|
||||
else None
|
||||
),
|
||||
rule=getattr(df, "rule", None),
|
||||
)
|
||||
|
||||
conditional_access_policy[tenant].update(
|
||||
{
|
||||
policy.id: ConditionalAccessPolicy(
|
||||
@@ -428,8 +391,6 @@ class Entra(AzureService):
|
||||
"grant": grant_access_controls,
|
||||
"block": block_access_controls,
|
||||
},
|
||||
sign_in_frequency=sign_in_frequency,
|
||||
device_filter=device_filter,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -496,30 +457,10 @@ class DirectoryRole(BaseModel):
|
||||
members: List[User]
|
||||
|
||||
|
||||
class SignInFrequencySessionControl(BaseModel):
|
||||
"""Sign-in frequency session control settings."""
|
||||
|
||||
is_enabled: bool = False
|
||||
frequency_interval: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
value: Optional[int] = None
|
||||
|
||||
|
||||
class DeviceFilter(BaseModel):
|
||||
"""Device filter for conditional access policies."""
|
||||
|
||||
mode: Optional[str] = None
|
||||
rule: Optional[str] = None
|
||||
|
||||
|
||||
class ConditionalAccessPolicy(BaseModel):
|
||||
"""Conditional Access Policy model."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
state: str
|
||||
users: dict[str, List[str]]
|
||||
target_resources: dict[str, List[str]]
|
||||
access_controls: dict[str, List[str]]
|
||||
sign_in_frequency: Optional[SignInFrequencySessionControl] = None
|
||||
device_filter: Optional[DeviceFilter] = None
|
||||
|
||||
@@ -15,6 +15,7 @@ from prowler.providers.azure.services.iam.iam_client import iam_client
|
||||
class entra_user_with_vm_access_has_mfa(Check):
|
||||
def execute(self) -> Check_Report_Azure:
|
||||
findings = []
|
||||
already_reported = set()
|
||||
|
||||
for users in entra_client.users.values():
|
||||
for user in users.values():
|
||||
@@ -22,6 +23,9 @@ class entra_user_with_vm_access_has_mfa(Check):
|
||||
subscription_name,
|
||||
role_assigns,
|
||||
) in iam_client.role_assignments.items():
|
||||
if (user.id, subscription_name) in already_reported:
|
||||
continue
|
||||
|
||||
for assignment in role_assigns.values():
|
||||
if (
|
||||
assignment.agent_type == "User"
|
||||
@@ -48,5 +52,7 @@ class entra_user_with_vm_access_has_mfa(Check):
|
||||
report.status_extended = f"User {user.name} can access VMs in subscription {subscription_name} but it has MFA."
|
||||
|
||||
findings.append(report)
|
||||
already_reported.add((user.id, subscription_name))
|
||||
break
|
||||
|
||||
return findings
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_bastion_host_exists",
|
||||
"CheckTitle": "Ensure an Azure Bastion Host Exists",
|
||||
"CheckTitle": "Azure subscription has at least one Bastion Host",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Network",
|
||||
"ResourceType": "microsoft.network/bastionhosts",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "The Azure Bastion service allows secure remote access to Azure Virtual Machines over the Internet without exposing remote access protocol ports and services directly to the Internet. The Azure Bastion service provides this access using TLS over 443/TCP, and subscribes to hardened configurations within an organization's Azure Active Directory service.",
|
||||
"Risk": "The Azure Bastion service allows organizations a more secure means of accessing Azure Virtual Machines over the Internet without assigning public IP addresses to those Virtual Machines. The Azure Bastion service provides Remote Desktop Protocol (RDP) and Secure Shell (SSH) access to Virtual Machines using TLS within a web browser, thus preventing organizations from opening up 3389/TCP and 22/TCP to the Internet on Azure Virtual Machines. Additional benefits of the Bastion service includes Multi-Factor Authentication, Conditional Access Policies, and any other hardening measures configured within Azure Active Directory using a central point of access.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/bastion/bastion-overview#sku",
|
||||
"Description": "**Azure subscription** contains an **Azure Bastion host** for secure RDP/SSH brokering over TLS on `443/TCP` to virtual machines using private IPs. The assessment identifies whether such a bastion is available.",
|
||||
"Risk": "Absent **Bastion**, admins often assign public IPs or open `22/3389`, expanding attack surface.\n\nThis enables Internet brute force, credential stuffing, and RDP/SSH exploits, leading to unauthorized access, data exfiltration, and lateral movement. CIA impact: confidentiality/integrity loss and potential downtime from ransomware.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/powershell/module/az.network/get-azbastion?view=azps-9.2.0",
|
||||
"https://learn.microsoft.com/en-us/azure/templates/microsoft.network/bastionhosts",
|
||||
"https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/bastion-host-exists.html",
|
||||
"https://learn.microsoft.com/en-us/azure/bastion/bastion-overview#sku",
|
||||
"https://learn.microsoft.com/en-us/azure/firewall/deploy-ps"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az network bastion create --location <location> --name <name of bastion host> --public-ip-address <public IP address name or ID> --resource-group <resource group name or ID> --vnet-name <virtual network containing subnet called 'AzureBastionSubnet'> --scale-units <integer> --sku Standard [--disable-copy- paste true|false] [--enable-ip-connect true|false] [--enable-tunneling true|false]",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/bastion-host-exists.html",
|
||||
"Terraform": ""
|
||||
"CLI": "az network bastion create --name <BASTION_NAME> --public-ip-address <PUBLIC_IP_NAME> --resource-group <RESOURCE_GROUP> --vnet-name <VNET_NAME> --location <LOCATION>",
|
||||
"NativeIaC": "```bicep\n// Minimal Bicep to ensure at least one Bastion Host exists in the subscription\nparam location string = resourceGroup().location\n\nresource vnet 'Microsoft.Network/virtualNetworks@2022-07-01' = {\n name: '<example_resource_name>-vnet'\n location: location\n properties: {\n addressSpace: { addressPrefixes: ['10.0.0.0/24'] }\n subnets: [\n {\n name: 'AzureBastionSubnet'\n properties: { addressPrefix: '10.0.0.0/27' }\n }\n ]\n }\n}\n\nresource pip 'Microsoft.Network/publicIPAddresses@2022-07-01' = {\n name: '<example_resource_name>-pip'\n location: location\n sku: { name: 'Standard' }\n properties: { publicIPAllocationMethod: 'Static' }\n}\n\nresource bastion 'Microsoft.Network/bastionHosts@2024-10-01' = {\n name: '<example_resource_name>'\n location: location\n sku: { name: 'Basic' }\n properties: {\n ipConfigurations: [\n {\n name: 'IpConf'\n properties: {\n subnet: { id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, 'AzureBastionSubnet') } // Critical: attaches Bastion to required AzureBastionSubnet so resource can be created\n publicIPAddress: { id: pip.id } // Critical: associates required Public IP with Bastion\n }\n }\n ]\n }\n}\n```",
|
||||
"Other": "1. In the Azure portal, go to Networking > Bastions > Create\n2. Select your Subscription and a Resource group\n3. Enter a Name and Region\n4. Under Virtual network, select an existing VNet or click Create new\n5. Ensure a subnet named AzureBastionSubnet exists with a /27 address space; create it if prompted\n6. For Public IP address, click Create new and accept defaults\n7. Click Review + create, then Create\n8. After deployment completes, the subscription now has a Bastion Host (check passes)",
|
||||
"Terraform": "```hcl\n# Minimal Terraform to create one Bastion Host (fixes FAIL by ensuring existence)\nresource \"azurerm_resource_group\" \"example\" {\n name = \"<example_resource_name>\"\n location = \"eastus\"\n}\n\nresource \"azurerm_virtual_network\" \"example\" {\n name = \"<example_resource_name>-vnet\"\n location = azurerm_resource_group.example.location\n resource_group_name = azurerm_resource_group.example.name\n address_space = [\"10.0.0.0/24\"]\n}\n\nresource \"azurerm_subnet\" \"bastion\" {\n name = \"AzureBastionSubnet\"\n resource_group_name = azurerm_resource_group.example.name\n virtual_network_name = azurerm_virtual_network.example.name\n address_prefixes = [\"10.0.0.0/27\"]\n}\n\nresource \"azurerm_public_ip\" \"example\" {\n name = \"<example_resource_name>-pip\"\n location = azurerm_resource_group.example.location\n resource_group_name = azurerm_resource_group.example.name\n allocation_method = \"Static\"\n sku = \"Standard\"\n}\n\nresource \"azurerm_bastion_host\" \"example\" {\n name = \"<example_resource_name>\"\n location = azurerm_resource_group.example.location\n resource_group_name = azurerm_resource_group.example.name\n\n # Critical: creating the Bastion Host resource is what changes the check to PASS\n sku = \"Basic\" # Critical: required for Bastion creation\n\n ip_configuration { \n name = \"IpConf\"\n subnet_id = azurerm_subnet.bastion.id # Critical: attaches Bastion to AzureBastionSubnet\n public_ip_address_id = azurerm_public_ip.example.id # Critical: associates required Public IP\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "From Azure Portal* 1. Click on Bastions 2. Select the Subscription 3. Select the Resource group 4. Type a Name for the new Bastion host 5. Select a Region 6. Choose Standard next to Tier 7. Use the slider to set the Instance count 8. Select the Virtual network or Create new 9. Select the Subnet named AzureBastionSubnet. Create a Subnet named AzureBastionSubnet using a /26 CIDR range if it doesn't already exist. 10. Selct the appropriate Public IP address option. 11. If Create new is selected for the Public IP address option, provide a Public IP address name. 12. If Use existing is selected for Public IP address option, select an IP address from Choose public IP address 13. Click Next: Tags > 14. Configure the appropriate Tags 15. Click Next: Advanced > 16. Select the appropriate Advanced options 17. Click Next: Review + create > 18. Click Create From Azure CLI az network bastion create --location <location> --name <name of bastion host> --public-ip-address <public IP address name or ID> --resource-group <resource group name or ID> --vnet-name <virtual network containing subnet called 'AzureBastionSubnet'> --scale-units <integer> --sku Standard [--disable-copy- paste true|false] [--enable-ip-connect true|false] [--enable-tunneling true|false] From PowerShell Create the appropriate Virtual network settings and Public IP Address settings. $subnetName = 'AzureBastionSubnet' $subnet = New-AzVirtualNetworkSubnetConfig -Name $subnetName -AddressPrefix <IP address range in CIDR notation making sure to use a /26> $virtualNet = New-AzVirtualNetwork -Name <virtual network name> - ResourceGroupName <resource group name> -Location <location> -AddressPrefix <IP address range in CIDR notation> -Subnet $subnet $publicip = New-AzPublicIpAddress -ResourceGroupName <resource group name> - Name <public IP address name> -Location <location> -AllocationMethod Dynamic -Sku Standard",
|
||||
"Url": "https://learn.microsoft.com/en-us/powershell/module/az.network/get-azbastion?view=azps-9.2.0"
|
||||
"Text": "Standardize on **Azure Bastion** for admin access.\n\nRemove VM public IPs and deny inbound `22`/`3389` via perimeter controls and NSGs. Apply **least privilege** and just-in-time access, integrate **Entra ID** with **MFA** and conditional access, monitor sessions/logs, and segment networks so only Bastion can reach management ports.",
|
||||
"Url": "https://hub.prowler.com/check/network_bastion_host_exists"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "The Azure Bastion service incurs additional costs and requires a specific virtual network configuration. The Standard tier offers additional configuration options compared to the Basic tier and may incur additional costs for those added features."
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_flow_log_captured_sent",
|
||||
"CheckTitle": "Ensure that network flow logs are captured and fed into a central log analytics workspace.",
|
||||
"CheckTitle": "Network Watcher has flow logs enabled and sent to a Log Analytics workspace",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Network",
|
||||
"ResourceType": "microsoft.network/networkwatchers",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace.",
|
||||
"Risk": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation",
|
||||
"Description": "**Azure Network Watcher** has **NSG flow logs** enabled and configured to forward traffic records to a centralized **Log Analytics workspace**",
|
||||
"Risk": "Missing or disabled flow logging blinds visibility into network behavior, hindering detection of:\n- **Lateral movement** and internal scanning\n- **C2 beacons** and exfiltration patterns\nThis degrades incident response and correlation, impacting **confidentiality** and **integrity**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-tutorial",
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"CLI": "az network watcher flow-log create --location <REGION> --name <FLOW_LOG_NAME> --resource-group <RESOURCE_GROUP> --nsg <NSG_NAME> --storage-account <STORAGE_ACCOUNT_NAME> --enabled true --workspace <LOG_ANALYTICS_WORKSPACE_ID>",
|
||||
"NativeIaC": "```bicep\n// Enable NSG flow logs and send to Log Analytics\nresource flowLog 'Microsoft.Network/networkWatchers/flowLogs@2022-09-01' = {\n name: '<example_resource_name>/<example_resource_name>'\n location: '<REGION>'\n properties: {\n enabled: true // CRITICAL: turns on flow logs\n targetResourceId: '<example_resource_id>' // NSG resource ID\n storageId: '<example_resource_id>' // required for NSG flow logs\n flowAnalyticsConfiguration: {\n networkWatcherFlowAnalyticsConfiguration: {\n enabled: true // CRITICAL: sends flow logs to Log Analytics\n workspaceResourceId: '<example_resource_id>' // Log Analytics workspace resource ID\n }\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure portal, go to Network Watcher > Flow logs\n2. Click + Create (or Create flow log)\n3. Select the target NSG and region\n4. Set Status to On\n5. Select a Storage account\n6. Enable Traffic analytics, then select your Log Analytics workspace\n7. Click Review + create, then Create",
|
||||
"Terraform": "```hcl\n# Enable NSG flow logs and send to Log Analytics\nresource \"azurerm_network_watcher_flow_log\" \"<example_resource_name>\" {\n network_watcher_name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n network_security_group_id = \"<example_resource_id>\"\n storage_account_id = \"<example_resource_id>\"\n\n enabled = true # CRITICAL: turns on flow logs\n\n traffic_analytics { \n enabled = true # CRITICAL: sends flow logs to Log Analytics\n workspace_id = \"<example_resource_id>\" # workspace_id (GUID) or use data source\n workspace_region = \"<REGION>\"\n workspace_resource_id = \"<example_resource_id>\" # Log Analytics workspace resource ID\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "1. Navigate to Network Watcher. 2. Select NSG flow logs. 3. Select + Create. 4. Select the desired Subscription. 5. Select + Select NSG. 6. Select a network security group. 7. Click Confirm selection. 8. Select or create a new Storage Account. 9. Input the retention in days to retain the log. 10. Click Next. 11. Under Configuration, select Version 2. 12. If rich analytics are required, select Enable Traffic Analytics, a processing interval, and a Log Analytics Workspace. 13. Select Next. 14. Optionally add Tags. 15. Select Review + create. 16. Select Create. Warning The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal"
|
||||
"Text": "Enable and centrally aggregate **NSG flow logs** to a **Log Analytics workspace**.\n\n- Enforce least privilege on log data\n- Define retention and secure storage\n- Use layered monitoring (e.g., Traffic Analytics)\n- Ensure coverage across regions/subscriptions and critical NSGs",
|
||||
"Url": "https://hub.prowler.com/check/network_flow_log_captured_sent"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"logging",
|
||||
"forensics-ready"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor."
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_flow_log_more_than_90_days",
|
||||
"CheckTitle": "Ensure that Network Security Group Flow Log retention period is 0, 90 days or greater",
|
||||
"CheckTitle": "Network Watcher has all flow logs enabled with retention set to 0 or at least 90 days",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Network",
|
||||
"ResourceType": "microsoft.network/networkwatchers",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days.",
|
||||
"Risk": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.",
|
||||
"RelatedUrl": " https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview",
|
||||
"Description": "**Azure Network Watcher** has **NSG flow logs** enabled and configured to retain for at least `90` days (or `0` for unlimited). The evaluation checks that flow logging is enabled and that the retention policy meets the required duration for each configured log.",
|
||||
"Risk": "Absent or short-retained **NSG flow logs** reduce visibility into IP flows, delaying detection of port scans, brute force, data exfiltration, and lateral movement.\n\nForensics and accountability degrade, threatening **confidentiality** and **integrity**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest",
|
||||
"https://learn.microsoft.com/en-us/azure/network-watcher/nsg-flow-logs-overview?tabs=Americas",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Network/sufficient-nsg-flow-log-retention-period.html",
|
||||
"https://support.icompaas.com/support/solutions/articles/62000229906-ensure-that-network-security-group-flow-log-retention-period-is-greater-than-90-days-"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az network watcher flow-log configure --nsg <NameorID of the Network Security Group> --enabled true --resource-group <resourceGroupName> --retention 91 -- storage-account <NameorID of the storage account to save flow logs>",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/sufficient-nsg-flow-log-retention-period.html",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-logging-policies/bc_azr_logging_1#terraform"
|
||||
"CLI": "az network watcher flow-log create --location <LOCATION> --name <example_resource_name> --nsg <example_resource_id> --storage-account <example_resource_id> --retention 90",
|
||||
"NativeIaC": "```bicep\n// Enable NSG flow logs with retention >= 90 days\nresource flowlog 'Microsoft.Network/networkWatchers/flowLogs@2023-09-01' = {\n name: '<example_resource_name>/<example_resource_name>'\n location: '<LOCATION>'\n properties: {\n targetResourceId: '<example_resource_id>'\n storageId: '<example_resource_id>'\n enabled: true // critical: turns on flow logs\n retentionPolicy: {\n enabled: true // critical: activates retention policy\n days: 90 // critical: 0 (unlimited) or >= 90 to pass\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Network Watcher > NSG flow logs\n2. Select the NSG to configure\n3. Set Status to On\n4. Set Retention (days) to 0 (unlimited) or at least 90\n5. Select a Storage account\n6. Click Save",
|
||||
"Terraform": "```hcl\n# Enable NSG flow logs with retention >= 90 days\nresource \"azurerm_network_watcher_flow_log\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n network_watcher_name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n target_resource_id = \"<example_resource_id>\"\n storage_account_id = \"<example_resource_id>\"\n\n enabled = true # critical: turns on flow logs\n\n retention_policy {\n enabled = true # critical: activates retention policy\n days = 90 # critical: 0 (unlimited) or >= 90 to pass\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "From Azure Portal 1. Go to Network Watcher 2. Select NSG flow logs blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure Status is set to On 5. Ensure Retention (days) setting greater than 90 days 6. Select your storage account in the Storage account field 7. Select Save From Azure CLI Enable the NSG flow logs and set the Retention (days) to greater than or equal to 90 days. az network watcher flow-log configure --nsg <NameorID of the Network Security Group> --enabled true --resource-group <resourceGroupName> --retention 91 --storage-account <NameorID of the storage account to save flow logs>",
|
||||
"Url": "https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest"
|
||||
"Text": "Enable **NSG flow logs** and keep retention `90` days (`0` for unlimited). Restrict and monitor access to logs, store immutably, and stream to a SIEM to detect anomalies. Apply **defense in depth** and **least privilege**. Plan migration to **Virtual network flow logs** as NSG flow logs are being retired.",
|
||||
"Url": "https://hub.prowler.com/check/network_flow_log_more_than_90_days"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"logging",
|
||||
"forensics-ready"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use."
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_http_internet_access_restricted",
|
||||
"CheckTitle": "Ensure that HTTP(S) access from the Internet is evaluated and restricted",
|
||||
"CheckTitle": "Network security group restricts inbound HTTP (port 80) access from the Internet",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Network",
|
||||
"ResourceType": "microsoft.network/networksecuritygroups",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Network security groups should be periodically evaluated for port misconfigurations. Where certain ports and protocols may be exposed to the Internet, they should be evaluated for necessity and restricted wherever they are not explicitly required and narrowly configured.",
|
||||
"Risk": "The potential security problem with using HTTP(S) over the Internet is that attackers can use various brute force techniques to gain access to Azure resources. Once the attackers gain access, they can use the resource as a launch point for compromising other resources within the Azure tenant.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-network-security#ns-1-establish-network-segmentation-boundaries",
|
||||
"Description": "**Azure NSG** are evaluated for inbound rules that allow public **HTTP** access on `TCP 80`, including cases where `80` is covered by a port range, from `0.0.0.0/0`, `Internet`, or `*`.",
|
||||
"Risk": "Exposing `TCP 80` to the Internet increases attack surface:\n- Web recon and exploits compromise **integrity** and **availability**\n- Cleartext HTTP can leak credentials, cookies, and data, harming **confidentiality**\n- Public endpoints enable bot abuse and footholds for lateral movement",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/unrestricted-http-access.html",
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-network-security#ns-1-establish-network-segmentation-boundaries"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/unrestricted-http-access.html",
|
||||
"Terraform": ""
|
||||
"CLI": "az network nsg rule update --resource-group <RESOURCE_GROUP> --nsg-name <NSG_NAME> --name <RULE_NAME> --access Deny",
|
||||
"NativeIaC": "```bicep\n// Deny inbound HTTP from Internet on an existing NSG\nresource nsg 'Microsoft.Network/networkSecurityGroups@2023-09-01' existing = {\n name: '<example_resource_name>'\n}\n\nresource denyHttp 'Microsoft.Network/networkSecurityGroups/securityRules@2023-09-01' = {\n name: '${nsg.name}/Deny-HTTP-Internet'\n properties: {\n priority: 100\n direction: 'Inbound'\n access: 'Deny' // CRITICAL: Denies the HTTP rule so it no longer allows Internet traffic\n protocol: 'Tcp'\n sourceAddressPrefix: 'Internet' // CRITICAL: Targets traffic coming from the Internet\n destinationAddressPrefix: '*'\n sourcePortRange: '*'\n destinationPortRange: '80' // CRITICAL: Blocks port 80 (HTTP)\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Network Security Groups and select your NSG\n2. Open Inbound security rules\n3. Find any rule with Action Allow, Protocol TCP or Any, Destination port 80 (or range including 80), and Source Internet/*/0.0.0.0/0\n4. Select the rule and click Edit\n5. Change Action to Deny (or delete the rule)\n6. Click Save",
|
||||
"Terraform": "```hcl\n# Deny inbound HTTP from Internet on an existing NSG\nresource \"azurerm_network_security_rule\" \"deny_http_internet\" {\n name = \"deny-http-internet\"\n resource_group_name = \"<example_resource_name>\"\n network_security_group_name = \"<example_resource_name>\"\n priority = 100\n direction = \"Inbound\"\n access = \"Deny\" # CRITICAL: Deny so HTTP from Internet is not allowed\n protocol = \"Tcp\"\n source_address_prefix = \"Internet\" # CRITICAL: Matches traffic from the Internet\n destination_address_prefix = \"*\"\n source_port_range = \"*\"\n destination_port_range = \"80\" # CRITICAL: Blocks port 80 (HTTP)\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Where HTTP(S) is not explicitly required and narrowly configured for resources attached to the Network Security Group, Internet-level access to your Azure resources should be restricted or eliminated. For internal access to relevant resources, configure an encrypted network tunnel such as: ExpressRoute Site-to-site VPN Point-to-site VPN",
|
||||
"Url": ""
|
||||
"Text": "Apply **least privilege** at NSGs:\n- Remove broad allows to `TCP 80`, or restrict to trusted sources\n- Enforce **HTTPS (443)** and redirect or block HTTP\n- Use **private access** patterns and segmentation for **defense in depth**\n- If exposure is necessary, place services behind a **WAF**, enable **DDoS** protections, and monitor",
|
||||
"Url": "https://hub.prowler.com/check/network_http_internet_access_restricted"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_public_ip_shodan",
|
||||
"CheckTitle": "Check if an Azure Public IP is exposed in Shodan (requires Shodan API KEY).",
|
||||
"CheckTitle": "Azure public IP address is not listed in Shodan",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Network",
|
||||
"ResourceType": "microsoft.network/publicipaddresses",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Check if an Azure Public IP is exposed in Shodan (requires Shodan API KEY).",
|
||||
"Risk": "If an Azure Public IP is exposed in Shodan, it can be accessed by anyone on the internet. This can lead to unauthorized access to your resources.",
|
||||
"Description": "**Azure Public IP addresses** are detected as **indexed by Shodan**, indicating Internet-visible services with open `ports` and service banner metadata.",
|
||||
"Risk": "Shodan-visible IPs are easy to discover and target, elevating risks to **confidentiality** and **integrity**. Adversaries can enumerate banners, probe open ports, brute-force access, and exploit known CVEs, enabling unauthorized entry, data exfiltration, and lateral movement.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.shodan.io/",
|
||||
"https://support.icompaas.com/support/solutions/articles/62000235334-ensure-any-public-addresses-are-listed-in-shodan-using-shodan-api-",
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/public-ip-addresses"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"CLI": "az network public-ip delete --resource-group <RESOURCE_GROUP> --name <PUBLIC_IP_NAME>",
|
||||
"NativeIaC": "```bicep\n// Remove public exposure by NOT associating a public IP\nresource subnet 'Microsoft.Network/virtualNetworks/subnets@2023-11-01' existing = {\n name: '<example_vnet_name>/<example_subnet_name>'\n}\n\nresource nic 'Microsoft.Network/networkInterfaces@2023-11-01' = {\n name: '<example_resource_name>'\n location: resourceGroup().location\n properties: {\n ipConfigurations: [\n {\n name: 'ipconfig1'\n properties: {\n privateIPAllocationMethod: 'Dynamic'\n subnet: { id: subnet.id }\n // CRITICAL: No 'publicIPAddress' property -> NIC has no public IP, preventing Shodan listing\n }\n }\n ]\n }\n}\n```",
|
||||
"Other": "1. In the Azure portal, go to Public IP addresses and select the affected IP\n2. Click Dissociate and confirm to remove it from the attached resource\n3. Click Delete to remove the Public IP from your subscription",
|
||||
"Terraform": "```hcl\n# NIC without a public IP association\nresource \"azurerm_network_interface\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n location = \"<LOCATION>\"\n resource_group_name = \"<RESOURCE_GROUP>\"\n\n ip_configuration {\n name = \"ipconfig1\"\n subnet_id = \"<example_resource_id>\"\n private_ip_address_allocation = \"Dynamic\"\n # CRITICAL: Omit public_ip_address_id -> no public IP, preventing Shodan listing\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Check Identified IPs, Consider changing them to private ones and delete them from Shodan.",
|
||||
"Url": "https://www.shodan.io/"
|
||||
"Text": "Minimize **public exposure**: prefer **private endpoints** or VPN/bastion, restrict ingress per least privilege (avoid `0.0.0.0/0`), close unused ports, patch and harden services, and apply defense-in-depth segmentation. Continuously inventory public IPs and rotate them if sensitive banners were exposed.",
|
||||
"Url": "https://hub.prowler.com/check/network_public_ip_shodan"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_rdp_internet_access_restricted",
|
||||
"CheckTitle": "Ensure that RDP access from the Internet is evaluated and restricted",
|
||||
"CheckTitle": "Network security group does not allow inbound RDP (TCP 3389) from the Internet",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Network",
|
||||
"ResourceType": "microsoft.network/networksecuritygroups",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Network security groups should be periodically evaluated for port misconfigurations. Where certain ports and protocols may be exposed to the Internet, they should be evaluated for necessity and restricted wherever they are not explicitly required.",
|
||||
"Risk": "The potential security problem with using RDP over the Internet is that attackers can use various brute force techniques to gain access to Azure Virtual Machines. Once the attackers gain access, they can use a virtual machine as a launch point for compromising other machines on an Azure Virtual Network or even attack networked devices outside of Azure.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security/azure-security-network-security-best-practices#disable-rdpssh-access-to-azure-virtual-machines",
|
||||
"Description": "**Azure NSG inbound rules** are evaluated for **public RDP exposure**. The finding flags rules that `Allow` `TCP` traffic to `port 3389` from broad sources like `0.0.0.0/0`, `Internet`, or `*`, including ranges that cover `3389`.",
|
||||
"Risk": "Exposed **RDP** enables Internet-wide **brute force** and **credential stuffing**, risking unauthorized console access.\n\nCompromise can cause data theft (**confidentiality**), tampering or malware deployment (**integrity**), VM lockout or disruption (**availability**), and **lateral movement** within the VNet.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Network/unrestricted-rdp-access.html",
|
||||
"https://learn.microsoft.com/en-za/answers/questions/1374791/policy-to-block-the-creation-of-nsgs-with-rules-th",
|
||||
"https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices#disable-rdpssh-access-to-azure-virtual-machines",
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-network-security#ns-1-establish-network-segmentation-boundaries"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/unrestricted-rdp-access.html#",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-networking-policies/bc_azr_networking_2#terraform"
|
||||
"CLI": "az network nsg rule delete --resource-group <RESOURCE_GROUP> --nsg-name <example_resource_name> --name <RDP_RULE_NAME>",
|
||||
"NativeIaC": "```bicep\n// NSG with RDP allowed only from a restricted CIDR (not Internet)\nresource nsg 'Microsoft.Network/networkSecurityGroups@2023-09-01' = {\n name: '<example_resource_name>'\n location: '<region>'\n properties: {\n securityRules: [\n {\n name: 'Allow-RDP-Restricted'\n properties: {\n priority: 100\n direction: 'Inbound'\n access: 'Allow'\n protocol: 'Tcp'\n sourcePortRange: '*'\n destinationPortRange: '3389'\n destinationAddressPrefix: '*'\n sourceAddressPrefix: '<AUTHORIZED_CIDR>' // CRITICAL: restrict source; not \"Internet\", \"*\", or \"0.0.0.0/0\" to pass the check\n }\n }\n ]\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Network Security Groups and open the NSG attached to the resource\n2. Select Inbound security rules\n3. Find any rule that allows TCP 3389 with Source set to Any/Internet/*/0.0.0.0/0\n4. Delete the rule, or edit it and set Source to a specific IP/CIDR (e.g., <AUTHORIZED_CIDR>)\n5. Save",
|
||||
"Terraform": "```hcl\n# NSG with RDP allowed only from a restricted CIDR (not Internet)\nresource \"azurerm_network_security_group\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n location = \"<region>\"\n resource_group_name = \"<example_resource_name>\"\n\n security_rule {\n name = \"Allow-RDP-Restricted\"\n priority = 100\n direction = \"Inbound\"\n access = \"Allow\"\n protocol = \"Tcp\"\n source_port_range = \"*\"\n destination_port_range = \"3389\"\n destination_address_prefix = \"*\"\n source_address_prefix = \"<AUTHORIZED_CIDR>\" # CRITICAL: restrict source; not \"*\", \"Internet\", or \"0.0.0.0/0\" so the check passes\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Where RDP is not explicitly required and narrowly configured for resources attached to the Network Security Group, Internet-level access to your Azure resources should be restricted or eliminated. For internal access to relevant resources, configure an encrypted network tunnel such as: ExpressRoute Site-to-site VPN Point-to-site VPN",
|
||||
"Url": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-network-security#ns-1-establish-network-segmentation-boundaries"
|
||||
"Text": "Enforce **least privilege** for remote admin:\n- Remove `Allow` to `3389` from `0.0.0.0/0`\n- Limit access to fixed IPs or private networks\n- Prefer Azure Bastion, JIT, or VPN/ExpressRoute\n- Harden auth (strong keys, MFA) and monitor\n\nAdopt **zero trust** and **defense in depth**.",
|
||||
"Url": "https://hub.prowler.com/check/network_rdp_internet_access_restricted"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_ssh_internet_access_restricted",
|
||||
"CheckTitle": "Ensure that SSH access from the Internet is evaluated and restricted",
|
||||
"CheckTitle": "Network security group does not allow inbound SSH (TCP port 22) from the Internet",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Network",
|
||||
"ResourceType": "microsoft.network/networksecuritygroups",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Network security groups should be periodically evaluated for port misconfigurations. Where certain ports and protocols may be exposed to the Internet, they should be evaluated for necessity and restricted wherever they are not explicitly required.",
|
||||
"Risk": "The potential security problem with using SSH over the Internet is that attackers can use various brute force techniques to gain access to Azure Virtual Machines. Once the attackers gain access, they can use a virtual machine as a launch point for compromising other machines on the Azure Virtual Network or even attack networked devices outside of Azure.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security/azure-security-network-security-best-practices#disable-rdpssh-access-to-azure-virtual-machines",
|
||||
"Description": "**Azure NSG** inbound rules that allow **SSH** on `TCP 22` from `0.0.0.0/0`, `Internet`, or `*` are identified, including rules where port ranges include `22` and protocol is `TCP` or `*`.\n\nIndicates NSGs exposing SSH to the Internet.",
|
||||
"Risk": "Public **SSH** access weakens **confidentiality** and **integrity**. Open `22` invites brute force and key theft, enabling remote shell control, persistence, and **lateral movement**. Attackers can pivot into VNets, exfiltrate data, deploy crypto-miners, and impact **availability**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Network/unrestricted-ssh-access.html",
|
||||
"https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices#disable-rdpssh-access-to-azure-virtual-machines",
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-network-security#ns-1-establish-network-segmentation-boundaries"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/unrestricted-ssh-access.html",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-networking-policies/bc_azr_networking_3#terraform"
|
||||
"CLI": "az network nsg rule delete --resource-group <RESOURCE_GROUP> --nsg-name <NSG_NAME> --name <RULE_NAME>",
|
||||
"NativeIaC": "```bicep\n// NSG allowing SSH only from a specific source (not the Internet)\nresource <example_resource_name> 'Microsoft.Network/networkSecurityGroups@2023-09-01' = {\n name: '<example_resource_name>'\n location: '<location>'\n properties: {\n securityRules: [\n {\n name: '<example_rule_name>'\n properties: {\n priority: 100\n direction: 'Inbound'\n access: 'Allow'\n protocol: 'Tcp'\n sourcePortRange: '*'\n destinationPortRange: '22'\n sourceAddressPrefix: '<ALLOWED_CIDR>' // CRITICAL: restrict SSH source; not Internet/*/0.0.0.0/0\n destinationAddressPrefix: '*'\n }\n }\n ]\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Network security groups and open <NSG_NAME>\n2. Select Inbound security rules\n3. Find any rule that allows TCP 22 from Internet/Any/0.0.0.0/0\n4. Delete the rule, or Edit it and set Source to IP Addresses with only your allowed CIDR(s)\n5. Save",
|
||||
"Terraform": "```hcl\n# Restrict SSH to a specific source so port 22 is not open to the Internet\nresource \"azurerm_network_security_rule\" \"<example_resource_name>\" {\n name = \"<example_rule_name>\"\n resource_group_name = \"<example_resource_name>\"\n network_security_group_name = \"<example_resource_name>\"\n priority = 100\n direction = \"Inbound\"\n access = \"Allow\"\n protocol = \"Tcp\"\n source_port_range = \"*\"\n destination_port_range = \"22\"\n source_address_prefix = \"<ALLOWED_CIDR>\" # CRITICAL: restrict SSH source; not Internet/*/0.0.0.0/0\n destination_address_prefix = \"*\"\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Where SSH is not explicitly required and narrowly configured for resources attached to the Network Security Group, Internet-level access to your Azure resources should be restricted or eliminated. For internal access to relevant resources, configure an encrypted network tunnel such as: ExpressRoute Site-to-site VPN Point-to-site VPN",
|
||||
"Url": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-network-security#ns-1-establish-network-segmentation-boundaries"
|
||||
"Text": "Apply **least privilege** on SSH:\n- Remove public rules to `TCP 22` from `0.0.0.0/0`\n- Allowlist specific admin IPs or management subnets\n- Prefer **Azure Bastion**, **JIT access**, or **VPN/ExpressRoute** for admin\n- Use key-based auth, disable passwords, and remove unnecessary public IPs\n\nAdopt **defense in depth** with logging and periodic reviews.",
|
||||
"Url": "https://hub.prowler.com/check/network_ssh_internet_access_restricted"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_udp_internet_access_restricted",
|
||||
"CheckTitle": "Ensure that UDP access from the Internet is evaluated and restricted",
|
||||
"CheckTitle": "Network security group does not allow inbound UDP from the Internet",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Network",
|
||||
"ResourceType": "microsoft.network/networksecuritygroups",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Network security groups should be periodically evaluated for port misconfigurations. Where certain ports and protocols may be exposed to the Internet, they should be evaluated for necessity and restricted wherever they are not explicitly required.",
|
||||
"Risk": "The potential security problem with broadly exposing UDP services over the Internet is that attackers can use DDoS amplification techniques to reflect spoofed UDP traffic from Azure Virtual Machines. The most common types of these attacks use exposed DNS, NTP, SSDP, SNMP, CLDAP and other UDP-based services as amplification sources for disrupting services of other machines on the Azure Virtual Network or even attack networked devices outside of Azure.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security/fundamentals/network-best-practices#secure-your-critical-azure-service-resources-to-only-your-virtual-networks",
|
||||
"Description": "**Azure NSG rules** are assessed for **inbound UDP** exposure to the public Internet (e.g., `0.0.0.0/0`, `*`, `Internet`). The finding identifies allow rules that permit unsolicited **UDP** traffic from any external source to attached resources.",
|
||||
"Risk": "Publicly reachable **UDP** services enable **DDoS reflection/amplification**, exhausting bandwidth and compute and degrading **availability** for workloads and networks. Open services (DNS, NTP, SSDP, SNMP, CLDAP) can be abused with spoofed traffic, turning endpoints into amplifiers and disrupting adjacent resources.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"http://learn.microsoft.com/en-us/azure/ddos-protection/fundamental-best-practices",
|
||||
"https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices#secure-your-critical-azure-service-resources-to-only-your-virtual-networks",
|
||||
"https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/unrestricted-udp-access.html#"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/unrestricted-udp-access.html#",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-networking-policies/ensure-that-udp-services-are-restricted-from-the-internet#terraform"
|
||||
"CLI": "az network nsg rule update --resource-group <RESOURCE_GROUP> --nsg-name <NSG_NAME> --name <RULE_NAME> --access Deny",
|
||||
"NativeIaC": "```bicep\n// Update the existing NSG rule to block UDP from the Internet\nresource nsg 'Microsoft.Network/networkSecurityGroups@2023-09-01' existing = {\n name: '<example_resource_name>'\n}\n\nresource rule 'Microsoft.Network/networkSecurityGroups/securityRules@2023-09-01' = {\n name: '${nsg.name}/<example_resource_name>'\n properties: {\n priority: 100\n direction: 'Inbound'\n access: 'Deny' // CRITICAL: Change access to Deny so UDP from Internet is not allowed\n protocol: 'Udp'\n sourceAddressPrefix: '*'\n destinationAddressPrefix: '*'\n sourcePortRange: '*'\n destinationPortRange: '*'\n }\n}\n```",
|
||||
"Other": "1. In the Azure portal, go to Network security groups and open the NSG attached to the resource\n2. Select Inbound security rules\n3. Find any rule with Protocol = UDP, Direction = Inbound, Action = Allow, and Source set to Any/Internet/0.0.0.0/0\n4. Click the rule, set Action to Deny (or change Source to a specific trusted range), then Save\n5. Repeat for any remaining UDP Allow rules from the Internet",
|
||||
"Terraform": "```hcl\n# Modify the existing NSG rule to deny UDP from the Internet\nresource \"azurerm_network_security_rule\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\" # existing rule name\n resource_group_name = \"<example_resource_name>\"\n network_security_group_name = \"<example_resource_name>\"\n priority = 100\n direction = \"Inbound\"\n access = \"Deny\" # CRITICAL: Change access to Deny to remove the Allow condition\n protocol = \"Udp\"\n source_address_prefix = \"*\"\n destination_address_prefix = \"*\"\n source_port_range = \"*\"\n destination_port_range = \"*\"\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Where UDP is not explicitly required and narrowly configured for resources attached tothe Network Security Group, Internet-level access to your Azure resources should be restricted or eliminated. For internal access to relevant resources, configure an encrypted network tunnel such as: ExpressRouteSite-to-site VPN Point-to-site VPN",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/security/fundamentals/ddos-best-practices"
|
||||
"Text": "Apply **least privilege** on NSG rules:\n- Deny Internet `UDP` inbound by default\n- Allow only required sources/ports\n- Prefer private access (VNets, private endpoints, VPN/ExpressRoute)\n- Use **defense in depth** with Azure Firewall and DDoS Protection\n- Monitor and disable or rate-limit unnecessary UDP services",
|
||||
"Url": "https://hub.prowler.com/check/network_udp_internet_access_restricted"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "network_watcher_enabled",
|
||||
"CheckTitle": "Ensure that Network Watcher is 'Enabled' for all locations in the Azure subscription",
|
||||
"CheckTitle": "Network Watcher is enabled for all locations in the subscription",
|
||||
"CheckType": [],
|
||||
"ServiceName": "network",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Network",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.network/networkwatchers",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Enable Network Watcher for Azure subscriptions.",
|
||||
"Risk": "Network diagnostic and visualization tools available with Network Watcher help users understand, diagnose, and gain insights to the network in Azure.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-monitoring-overview",
|
||||
"Description": "**Azure Network Watcher** presence across the subscription's regions. The assessment checks that a Network Watcher instance exists in every subscription location where resources may be deployed.",
|
||||
"Risk": "Absent **Network Watcher** in a region creates blind spots in **network telemetry and diagnostics**, hindering detection of anomalies. Attackers can exploit unnoticed NSG or routing issues for lateral movement or data exfiltration, degrading **confidentiality** and **availability** and slowing incident triage.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-logging-threat-detection#lt-3-enable-logging-for-azure-network-activities",
|
||||
"https://learn.microsoft.com/en-us/azure/network-watcher/network-watcher-overview",
|
||||
"https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/enable-network-watcher.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/Network/enable-network-watcher.html",
|
||||
"Terraform": ""
|
||||
"CLI": "az network watcher configure --resource-group NetworkWatcherRG --enabled true --locations <LOCATION_1> <LOCATION_2>",
|
||||
"NativeIaC": "```bicep\n// Deploy this to the resource group named \"NetworkWatcherRG\"\nparam locations array\n\nresource watchers 'Microsoft.Network/networkWatchers@2023-09-01' = [for loc in locations: {\n name: 'NetworkWatcher_${loc}'\n location: loc // CRITICAL: creates a Network Watcher in the specified region\n}]\n```",
|
||||
"Other": "1. In Azure Portal, search for \"Network Watcher\" and open it\n2. Select the target subscription\n3. In Overview, under Regions, for each region with Status = Disabled, click Enable\n4. Confirm all regions show Enabled",
|
||||
"Terraform": "```hcl\nvariable \"locations\" {\n type = list(string)\n}\n\nresource \"azurerm_network_watcher\" \"watchers\" {\n for_each = toset(var.locations)\n name = \"NetworkWatcher_${each.value}\"\n location = each.value # CRITICAL: ensures a watcher exists in this region\n resource_group_name = \"NetworkWatcherRG\" # CRITICAL: place in NetworkWatcherRG as expected by the check\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Opting out of Network Watcher automatic enablement is a permanent change. Once you opt-out you cannot opt-in without contacting support.",
|
||||
"Url": "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-logging-threat-detection#lt-3-enable-logging-for-azure-network-activities"
|
||||
"Text": "Enable **Network Watcher** in all regions and keep it enabled as your footprint expands.\n\nApply **defense in depth** by centralizing network logs and analytics, enforce coverage with policy, and restrict tool access by **least privilege**. Align retention and monitoring to support timely detection and investigation.",
|
||||
"Url": "https://hub.prowler.com/check/network_watcher_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"logging",
|
||||
"forensics-ready"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "There are additional costs per transaction to run and store network data. For high-volume networks these charges will add up quickly."
|
||||
|
||||