Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6667ec2cb | ||
|
|
2a5a95eb6d | ||
|
|
d8a0df82cc | ||
|
|
e293d24bb9 | ||
|
|
0940eba0de | ||
|
|
6cc6653256 | ||
|
|
489e5dc7cf | ||
|
|
9d82875037 | ||
|
|
685d24dfee | ||
|
|
2298d4a3f8 | ||
|
|
066d53467a | ||
|
|
cf433128ed | ||
|
|
0b782fcb8c | ||
|
|
b80e3a7bfb | ||
|
|
885555e080 | ||
|
|
641c418816 | ||
|
|
10d173f8da | ||
|
|
34de660755 | ||
|
|
fb75146e34 | ||
|
|
9f5ef80e69 | ||
|
|
2f6aedf291 | ||
|
|
dcf2736e8d | ||
|
|
7f0dc9b7da | ||
|
|
ff45f46047 | ||
|
|
3bd13d173d | ||
|
|
2b7f7e7dc0 | ||
|
|
98015dafef | ||
|
|
d70a7e3d02 | ||
|
|
7d2a22c45a | ||
|
|
bf82e9ff3d | ||
|
|
eece938350 | ||
|
|
2b0e34818c | ||
|
|
f587dbf419 | ||
|
|
2d684c1996 | ||
|
|
7f9d64a996 | ||
|
|
e943ded978 | ||
|
|
bb20f69a63 | ||
|
|
97233189c3 | ||
|
|
b624818b8e | ||
|
|
cb31856025 | ||
|
|
13a9caa803 | ||
|
|
4e22289a19 | ||
|
|
e035e0ff62 | ||
|
|
dd81793480 | ||
|
|
457297a5f6 | ||
|
|
4da5aed519 | ||
|
|
95521d26cb | ||
|
|
cbe06314ca | ||
|
|
4b72cc8dd4 | ||
|
|
ce9d46065a | ||
|
|
35b3ff2c8e | ||
|
|
d7d4cc4849 | ||
|
|
1459046985 | ||
|
|
84c3c9ce0a | ||
|
|
8271659fda | ||
|
|
9916e1ac66 | ||
|
|
ccec96ac5f | ||
|
|
f5ea116763 | ||
|
|
99ca260855 | ||
|
|
bfc6b9e577 | ||
|
|
bc3c922177 | ||
|
|
0e20d2388e | ||
|
|
e0ddc0de27 | ||
|
|
cf840588c7 | ||
|
|
e1c2e9373c | ||
|
|
641a11e4a0 | ||
|
|
59b13778e2 | ||
|
|
7d8c64d35b | ||
|
|
a022ad5c7a | ||
|
|
0f31f1a3c2 | ||
|
|
1ee71c1f20 | ||
|
|
0dc424031b | ||
|
|
0d899b3076 |
@@ -72,8 +72,8 @@ NEO4J_APOC_IMPORT_FILE_ENABLED=false
|
||||
NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true
|
||||
NEO4J_APOC_TRIGGER_ENABLED=false
|
||||
NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
|
||||
# Neo4j Prowler settings
|
||||
ATTACK_PATHS_BATCH_SIZE=1000
|
||||
# Attack Paths graph settings
|
||||
ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE=1000
|
||||
ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES=3
|
||||
ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS=30
|
||||
ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES=250
|
||||
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.34.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.36.1
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -113,6 +113,15 @@ jobs:
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
# api-container-build-push.yml resolves the SDK pin to the branch tip
|
||||
# before building, so match it here and scan what ships. Push only: PRs
|
||||
# stay deterministic against the committed lock.
|
||||
- name: Refresh prowler SDK pin to current branch tip
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && github.event_name == 'push'
|
||||
run: |
|
||||
pip install --no-cache-dir "uv==0.11.14"
|
||||
(cd api && uv lock --upgrade-package prowler)
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
@@ -35,6 +35,20 @@ CVE-2026-13221 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
|
||||
# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
|
||||
# to av_extend).
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
|
||||
# removed without breaking dpkg. Prowler does not invoke perl at runtime and
|
||||
# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
|
||||
# vulnerable deserialization path is unreachable. Fixed upstream in
|
||||
# Storable 3.41; no Debian bookworm fix is available yet.
|
||||
CVE-2026-57433 pkg:perl exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2025-7458 — SQLite integer overflow.
|
||||
# Package: libsqlite3-0.
|
||||
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
|
||||
|
||||
@@ -62,6 +62,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
@@ -84,6 +85,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating a universal (multi-provider) compliance framework | `prowler-compliance` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
|
||||
@@ -123,19 +123,19 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
|
||||
| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI |
|
||||
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
|
||||
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI |
|
||||
| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
|
||||
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
|
||||
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
|
||||
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
|
||||
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
|
||||
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
|
||||
|
||||
@@ -4,6 +4,42 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.37.0] (Prowler v5.36.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
|
||||
- Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database [(#11875)](https://github.com/prowler-cloud/prowler/pull/11875)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped [(#12002)](https://github.com/prowler-cloud/prowler/pull/12002)
|
||||
- Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Output generation now removes the scan's temporary output directory before writing, so a re-run of the task for the same scan (e.g. broker redelivery after a worker is killed mid-run) no longer appends to the previous run's files and duplicates finding rows in the exported CSV and other outputs [(#12097)](https://github.com/prowler-cloud/prowler/pull/12097)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers` [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Kubernetes kubeconfig validation now rejects legacy `auth-provider.config.cmd-path` command authentication in Prowler Cloud/API [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
|
||||
|
||||
---
|
||||
|
||||
## [1.36.0] (Prowler v5.35.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults [(#12009)](https://github.com/prowler-cloud/prowler/pull/12009)
|
||||
- Attack Paths scans handle provider deletion races cleanly, detect stale tasks after 16 hours, use backend-specific graph synchronization batches, and report exhausted Neptune write retries with the original database error [(#12019)](https://github.com/prowler-cloud/prowler/pull/12019)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
|
||||
- Social account linking requires a verified matching email from both the identity provider and the existing user account without sending account connection notifications [(#12013)](https://github.com/prowler-cloud/prowler/pull/12013)
|
||||
|
||||
---
|
||||
|
||||
## [1.35.0] (Prowler v5.34.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -102,7 +102,9 @@ ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
RUN uv sync --locked --no-install-project && \
|
||||
rm -rf ~/.cache/uv
|
||||
|
||||
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
|
||||
# Invoked as a module so the base image's Python minor version is not baked
|
||||
# into a site-packages path.
|
||||
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Authentication with an API key whose owning user was deleted now returns `401` instead of an unhandled `AttributeError`, and user deletion now revokes the user's API keys across all their tenants
|
||||
@@ -0,0 +1 @@
|
||||
AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.36",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.35.0"
|
||||
version = "1.37.1"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
@@ -11,6 +13,7 @@ from api.models import (
|
||||
)
|
||||
from api.utils import accept_invitation_for_user
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
|
||||
|
||||
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
@@ -38,8 +41,13 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
return None
|
||||
|
||||
def pre_social_login(self, request, sociallogin):
|
||||
# Link existing accounts with the same email address
|
||||
email = sociallogin.account.extra_data.get("email")
|
||||
# The provider account is already bound, so no email-based linking is needed.
|
||||
if sociallogin.account.pk:
|
||||
return
|
||||
|
||||
# Prefer the normalized email populated by allauth. GitHub can return the
|
||||
# primary email separately from the profile stored in extra_data.
|
||||
email = sociallogin.user.email or sociallogin.account.extra_data.get("email")
|
||||
if sociallogin.provider.id == "saml":
|
||||
# For SAML, the asserted NameID email cannot be trusted on its own:
|
||||
# any tenant can claim any email domain in its SAML configuration. To
|
||||
@@ -80,6 +88,17 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
if email:
|
||||
existing_user = self.get_user_by_email(email)
|
||||
if existing_user:
|
||||
email_is_verified = EmailAddress.objects.filter(
|
||||
user=existing_user,
|
||||
email__iexact=email,
|
||||
verified=True,
|
||||
).exists()
|
||||
provider_verified_email = any(
|
||||
address.verified and address.email.casefold() == email.casefold()
|
||||
for address in sociallogin.email_addresses
|
||||
)
|
||||
if not email_is_verified or not provider_verified_email:
|
||||
raise ImmediateHttpResponse(HttpResponseForbidden())
|
||||
sociallogin.connect(request, existing_user)
|
||||
|
||||
def save_user(self, request, sociallogin, form=None):
|
||||
|
||||
@@ -27,6 +27,7 @@ from django.conf import (
|
||||
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
|
||||
|
||||
TEMP_DB_PREFIX = "db-tmp-scan-"
|
||||
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
|
||||
|
||||
# Exceptions
|
||||
@@ -44,6 +45,10 @@ class GraphDatabaseQueryException(Exception):
|
||||
return self.message
|
||||
|
||||
|
||||
class NeptuneWriteRetryExhaustedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -10,6 +10,28 @@ import neo4j.exceptions
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RetryExhaustedError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
retry_context: str,
|
||||
method_name: str,
|
||||
attempts: int,
|
||||
elapsed_seconds: float,
|
||||
last_error: Exception,
|
||||
) -> None:
|
||||
self.retry_context = retry_context
|
||||
self.method_name = method_name
|
||||
self.attempts = attempts
|
||||
self.elapsed_seconds = elapsed_seconds
|
||||
self.last_error = last_error
|
||||
last_message = getattr(last_error, "message", None) or str(last_error)
|
||||
super().__init__(
|
||||
f"{retry_context} {method_name} failed after {attempts} attempts over "
|
||||
f"{elapsed_seconds:.3f}s. Last error: {last_message}"
|
||||
)
|
||||
|
||||
|
||||
class RetryableSession:
|
||||
"""Wrapper around ``neo4j.Session`` with a refreshable retry policy."""
|
||||
|
||||
@@ -19,11 +41,13 @@ class RetryableSession:
|
||||
max_retries: int,
|
||||
retry_if: Callable[[Exception], bool] | None = None,
|
||||
initial_retry_delay_seconds: float = 0,
|
||||
retry_context: str | None = None,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._max_retries = max(0, max_retries)
|
||||
self._retry_if = retry_if
|
||||
self._initial_retry_delay_seconds = max(0.0, initial_retry_delay_seconds)
|
||||
self._retry_context = retry_context
|
||||
self._session = self._session_factory()
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -54,6 +78,7 @@ class RetryableSession:
|
||||
def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
attempt = 0
|
||||
last_exc: Exception | None = None
|
||||
started_at = time.monotonic()
|
||||
|
||||
while attempt <= self._max_retries:
|
||||
try:
|
||||
@@ -68,17 +93,38 @@ class RetryableSession:
|
||||
attempt += 1
|
||||
|
||||
if attempt > self._max_retries:
|
||||
if self._retry_context is not None:
|
||||
raise RetryExhaustedError(
|
||||
retry_context=self._retry_context,
|
||||
method_name=method_name,
|
||||
attempts=attempt,
|
||||
elapsed_seconds=time.monotonic() - started_at,
|
||||
last_error=exc,
|
||||
) from exc
|
||||
raise
|
||||
|
||||
delay = self._retry_delay(attempt)
|
||||
logger.warning(
|
||||
"Graph session %s failed with %s; retry %s/%s in %.3fs",
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
if self._retry_context is not None:
|
||||
error_message = getattr(exc, "message", None) or str(exc)
|
||||
logger.warning(
|
||||
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
|
||||
self._retry_context,
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
error_message,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Graph session %s failed with %s; retry %s/%s in %.3fs",
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
self._refresh_session()
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
|
||||
@@ -15,6 +15,8 @@ class SinkDatabase(Protocol):
|
||||
has a single graph, and isolation is label-based).
|
||||
"""
|
||||
|
||||
sync_batch_size: int
|
||||
|
||||
def init(self) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
@@ -54,6 +54,8 @@ DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
class Neo4jSink(SinkDatabase):
|
||||
"""Neo4j-backed sink. Multi-database cluster; tenant isolation is physical."""
|
||||
|
||||
sync_batch_size = env.int("ATTACK_PATHS_NEO4J_SYNC_BATCH_SIZE", default=1000)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._driver: neo4j.Driver | None = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -203,7 +205,7 @@ class Neo4jSink(SinkDatabase):
|
||||
"""
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
@@ -251,7 +253,7 @@ class Neo4jSink(SinkDatabase):
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
@@ -270,7 +272,7 @@ class Neo4jSink(SinkDatabase):
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
|
||||
from api.attack_paths.sink.base import SinkDatabase
|
||||
from api.attack_paths.sink.drop import (
|
||||
NODE_DELETE_QUERY_TEMPLATE,
|
||||
@@ -85,6 +85,8 @@ def _is_retryable_write_error(exc: Exception) -> bool:
|
||||
class NeptuneSink(SinkDatabase):
|
||||
"""Neptune-backed sink. Single database; isolation is label-based."""
|
||||
|
||||
sync_batch_size = env.int("ATTACK_PATHS_NEPTUNE_SYNC_BATCH_SIZE", default=500)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._writer: neo4j.Driver | None = None
|
||||
self._reader: neo4j.Driver | None = None
|
||||
@@ -206,6 +208,7 @@ class NeptuneSink(SinkDatabase):
|
||||
from api.attack_paths.database import (
|
||||
ClientStatementException,
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
WriteQueryNotAllowedException,
|
||||
)
|
||||
|
||||
@@ -227,9 +230,17 @@ class NeptuneSink(SinkDatabase):
|
||||
initial_retry_delay_seconds=(
|
||||
NEPTUNE_WRITE_RETRY_DELAY_SECONDS if is_write_session else 0
|
||||
),
|
||||
retry_context="Neptune write" if is_write_session else None,
|
||||
)
|
||||
yield session_wrapper
|
||||
|
||||
except RetryExhaustedError as exc:
|
||||
last_error = exc.last_error
|
||||
raise NeptuneWriteRetryExhaustedException(
|
||||
message=str(exc),
|
||||
code=getattr(last_error, "code", None),
|
||||
) from last_error
|
||||
|
||||
except neo4j.exceptions.Neo4jError as exc:
|
||||
if (
|
||||
default_access_mode == neo4j.READ_ACCESS
|
||||
@@ -291,7 +302,7 @@ class NeptuneSink(SinkDatabase):
|
||||
graph's branching factor.
|
||||
"""
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
@@ -330,7 +341,7 @@ class NeptuneSink(SinkDatabase):
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
@@ -349,7 +360,7 @@ class NeptuneSink(SinkDatabase):
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from math import isfinite
|
||||
from uuid import UUID
|
||||
|
||||
@@ -5,6 +6,7 @@ from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey, TenantAPIKeyManager
|
||||
from cryptography.fernet import InvalidToken
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
@@ -14,6 +16,16 @@ from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.request import Request
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrphanedAPIKeyError(Exception):
|
||||
"""Raised when an API key outlived the user that owns it.
|
||||
|
||||
Handled by `authenticate`, which commits the revocation written while detecting it
|
||||
and then rejects the request with `AuthenticationFailed`.
|
||||
"""
|
||||
|
||||
|
||||
class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
model = TenantAPIKey
|
||||
@@ -24,10 +36,13 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
def _authenticate_credentials(self, request, key):
|
||||
"""
|
||||
Override to use admin connection, bypassing RLS during authentication.
|
||||
|
||||
Returns the validated API key row, locked with `select_for_update`, so callers
|
||||
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
|
||||
"""
|
||||
try:
|
||||
payload = self.key_crypto.decrypt(key)
|
||||
except ValueError:
|
||||
except (ValueError, InvalidToken):
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
@@ -52,13 +67,33 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
raise AuthenticationFailed("API Key has already expired.")
|
||||
|
||||
try:
|
||||
api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk)
|
||||
api_key = (
|
||||
self.model.objects.using(MainRouter.admin_db)
|
||||
.select_for_update()
|
||||
.get(id=api_key_pk)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
|
||||
if api_key.revoked:
|
||||
raise AuthenticationFailed("This API Key has been revoked.")
|
||||
|
||||
# `entity` is nullable and `on_delete=SET_NULL` leaves the key behind when its
|
||||
# owner is deleted, so a key can outlive its user. Reject it here: further down
|
||||
# the authentication would return `None` as the authenticated user, which blows
|
||||
# up while building the auth dict and surfaces as a 500 instead of a 401.
|
||||
# Revoke it as well, so it stops showing up as active and later attempts fail
|
||||
# the `revoked` check above like any other revoked key.
|
||||
if api_key.entity_id is None:
|
||||
api_key.revoked = True
|
||||
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
|
||||
logger.warning(
|
||||
"Revoked orphaned API key: prefix=%s tenant=%s",
|
||||
api_key.prefix,
|
||||
api_key.tenant_id,
|
||||
)
|
||||
raise OrphanedAPIKeyError
|
||||
|
||||
client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER)
|
||||
if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips:
|
||||
raise AuthenticationFailed("Access denied from blacklisted IP.")
|
||||
@@ -66,7 +101,7 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips:
|
||||
raise AuthenticationFailed("Access restricted to specific IP addresses.")
|
||||
|
||||
return api_key.entity, key
|
||||
return api_key
|
||||
|
||||
def authenticate(self, request: Request):
|
||||
prefixed_key = self.get_key(request)
|
||||
@@ -77,36 +112,34 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
except ValueError:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
try:
|
||||
entity, _ = self._authenticate_credentials(request, key)
|
||||
except InvalidToken:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
# Validation, the `last_used_at` update and the auth claims all read the same
|
||||
# row, locked until the transaction ends. Looking the key up a second time to
|
||||
# build the claims used to leave a window where a key revoked or orphaned right
|
||||
# after passing validation still authenticated.
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
try:
|
||||
api_key = self._authenticate_credentials(request, key)
|
||||
except OrphanedAPIKeyError:
|
||||
# Rejected below instead of here: leaving the block normally commits
|
||||
# the revocation `_authenticate_credentials` wrote, while raising from
|
||||
# inside would roll it back.
|
||||
pass
|
||||
else:
|
||||
# The prefix used to be checked by the second lookup
|
||||
if api_key.prefix != prefix:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# Get the API key instance to update last_used_at and retrieve tenant info
|
||||
# We need to decrypt again to get the pk (already validated by _authenticate_credentials)
|
||||
payload = self.key_crypto.decrypt(key)
|
||||
api_key_pk = payload["_pk"]
|
||||
api_key.last_used_at = timezone.now()
|
||||
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
|
||||
|
||||
# Convert string UUID back to UUID object for lookup
|
||||
if isinstance(api_key_pk, str):
|
||||
api_key_pk = UUID(api_key_pk)
|
||||
entity = api_key.entity
|
||||
return entity, {
|
||||
"tenant_id": str(api_key.tenant_id),
|
||||
"sub": str(entity.id),
|
||||
"api_key_prefix": api_key.prefix,
|
||||
}
|
||||
|
||||
try:
|
||||
api_key_instance = TenantAPIKey.objects.using(MainRouter.admin_db).get(
|
||||
id=api_key_pk, prefix=prefix
|
||||
)
|
||||
except TenantAPIKey.DoesNotExist:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# Update last_used_at
|
||||
api_key_instance.last_used_at = timezone.now()
|
||||
api_key_instance.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
|
||||
|
||||
return entity, {
|
||||
"tenant_id": str(api_key_instance.tenant_id),
|
||||
"sub": str(api_key_instance.entity.id),
|
||||
"api_key_prefix": prefix,
|
||||
}
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
|
||||
|
||||
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
|
||||
|
||||
@@ -3,9 +3,10 @@ from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
|
||||
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
|
||||
from api.filters import CustomDjangoFilterBackend
|
||||
from api.models import Role, UserRoleRelationship
|
||||
from api.rbac.permissions import HasPermissions
|
||||
from api.rbac.permissions import HasPermissions, get_role
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import NotAuthenticated
|
||||
from rest_framework.filters import SearchFilter
|
||||
@@ -100,6 +101,11 @@ class BaseRLSViewSet(BaseViewSet):
|
||||
context["tenant_id"] = self.request.tenant_id
|
||||
return context
|
||||
|
||||
@cached_property
|
||||
def user_role(self):
|
||||
"""Role of the requesting user in the active tenant, resolved once per request."""
|
||||
return get_role(self.request.user, self.request.tenant_id)
|
||||
|
||||
|
||||
class BaseTenantViewset(BaseViewSet):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import uuid
|
||||
from functools import wraps
|
||||
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import Provider, Scan
|
||||
from api.models import Membership, Provider, Scan, Tenant
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import DatabaseError, connection, transaction
|
||||
from django.db import DEFAULT_DB_ALIAS, DatabaseError, connection, transaction
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
|
||||
@@ -75,9 +76,11 @@ def handle_provider_deletion(func):
|
||||
"""
|
||||
Decorator that raises `ProviderDeletedException` if provider was deleted during execution.
|
||||
|
||||
Catches `ObjectDoesNotExist` and `DatabaseError` (including `IntegrityError`), checks if
|
||||
provider still exists, and raises `ProviderDeletedException` if not. Otherwise,
|
||||
re-raises original exception.
|
||||
Catches `ObjectDoesNotExist`, `DatabaseError` (including `IntegrityError`), and
|
||||
`GraphDatabaseQueryException`, checks if provider still exists, and raises
|
||||
`ProviderDeletedException` if not. Graph database errors also check whether the
|
||||
tenant still exists and has memberships. Otherwise, re-raises the original
|
||||
exception.
|
||||
|
||||
Requires `tenant_id` and `provider_id` in kwargs.
|
||||
|
||||
@@ -92,11 +95,16 @@ def handle_provider_deletion(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (ObjectDoesNotExist, DatabaseError):
|
||||
except (ObjectDoesNotExist, DatabaseError, GraphDatabaseQueryException) as exc:
|
||||
tenant_id = kwargs.get("tenant_id")
|
||||
provider_id = kwargs.get("provider_id")
|
||||
database_alias = (
|
||||
DEFAULT_DB_ALIAS
|
||||
if isinstance(exc, GraphDatabaseQueryException)
|
||||
else READ_REPLICA_ALIAS
|
||||
)
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
with rls_transaction(tenant_id, using=database_alias):
|
||||
if provider_id is None:
|
||||
scan_id = kwargs.get("scan_id")
|
||||
if scan_id is None:
|
||||
@@ -113,6 +121,13 @@ def handle_provider_deletion(func):
|
||||
raise ProviderDeletedException(
|
||||
f"Provider '{provider_id}' was deleted during the scan"
|
||||
) from None
|
||||
if isinstance(exc, GraphDatabaseQueryException) and (
|
||||
not Tenant.objects.filter(pk=tenant_id).exists()
|
||||
or not Membership.objects.filter(tenant_id=tenant_id).exists()
|
||||
):
|
||||
raise ProviderDeletedException(
|
||||
f"Tenant '{tenant_id}' was deleted during the scan"
|
||||
) from None
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Provider, Role, User
|
||||
from django.db.models import QuerySet
|
||||
from api.models import Integration, Provider, Role, User
|
||||
from django.db.models import Q, QuerySet
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
@@ -83,3 +83,32 @@ def get_providers(role: Role) -> QuerySet[Provider]:
|
||||
return Provider.objects.filter(
|
||||
tenant_id=tenant_id, provider_groups__in=provider_groups
|
||||
).distinct()
|
||||
|
||||
|
||||
def get_integrations(
|
||||
role: Role, providers: QuerySet[Provider] | None = None
|
||||
) -> QuerySet[Integration]:
|
||||
"""
|
||||
Return a distinct queryset of Integrations visible to the given role.
|
||||
|
||||
Integrations with no providers attached are tenant-wide, as is always the case for
|
||||
Jira, and stay visible regardless of the provider visibility of the role. Integrations
|
||||
attached to providers are only visible when the role can access at least one of them.
|
||||
|
||||
Args:
|
||||
role: A Role instance.
|
||||
providers: Optional queryset of the providers accessible by the role, to reuse
|
||||
an already resolved `get_providers(role)` result within the same request.
|
||||
|
||||
Returns:
|
||||
A QuerySet of Integration objects visible to the role.
|
||||
"""
|
||||
queryset = Integration.objects.filter(tenant_id=role.tenant_id)
|
||||
if role.unlimited_visibility:
|
||||
return queryset
|
||||
|
||||
if providers is None:
|
||||
providers = get_providers(role)
|
||||
return queryset.filter(
|
||||
Q(providers__isnull=True) | Q(providers__in=providers)
|
||||
).distinct()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import delete_related_daily_task
|
||||
from api.models import (
|
||||
LighthouseProviderConfiguration,
|
||||
@@ -47,8 +48,15 @@ def revoke_user_api_keys(sender, instance, **kwargs): # noqa: F841
|
||||
|
||||
The entity field will be set to NULL by on_delete=SET_NULL,
|
||||
but we explicitly revoke the keys to prevent further use.
|
||||
|
||||
The update runs on the admin connection because `api_keys` is RLS protected and its
|
||||
policy denies every row when `api.tenant_id` is unset. Users are deleted through the
|
||||
admin connection and may belong to several tenants, so going through the default
|
||||
connection would silently revoke nothing, or only the keys of the active tenant.
|
||||
"""
|
||||
TenantAPIKey.objects.filter(entity=instance).update(revoked=True)
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(entity=instance).update(
|
||||
revoked=True
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Membership)
|
||||
@@ -58,8 +66,12 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
|
||||
|
||||
When a membership is deleted, all API keys created by that user
|
||||
in that tenant should be revoked to prevent further access.
|
||||
|
||||
Uses the admin connection for the same reason as `revoke_user_api_keys`: the RLS
|
||||
policy on `api_keys` denies every row when `api.tenant_id` is unset, which is the
|
||||
case when the membership is removed as a cascade of a user deletion.
|
||||
"""
|
||||
TenantAPIKey.objects.filter(
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
|
||||
entity_id=instance.user_id, tenant_id=instance.tenant_id
|
||||
).update(revoked=True)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.35.0
|
||||
version: 1.37.1
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
@@ -6629,8 +6629,10 @@ paths:
|
||||
/api/v1/integrations:
|
||||
get:
|
||||
operationId: api_v1_integrations_list
|
||||
description: Retrieve a list of all configured integrations with options for
|
||||
filtering by various criteria.
|
||||
description: |-
|
||||
Retrieve a list of all configured integrations with options for filtering by various criteria.
|
||||
|
||||
Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role.
|
||||
summary: List all integrations
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6781,7 +6783,8 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_create
|
||||
description: Register a new integration with the system, providing necessary
|
||||
configuration details.
|
||||
configuration details. Only providers visible to the role can be attached
|
||||
to the integration.
|
||||
summary: Create a new integration
|
||||
tags:
|
||||
- Integration
|
||||
@@ -6810,7 +6813,7 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_jira_dispatches_create
|
||||
description: |-
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided.
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
@@ -6883,7 +6886,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_jira_issue_types_retrieve
|
||||
description: Fetch the available issue types from Jira for a given project key
|
||||
and update the integration configuration.
|
||||
and update the integration configuration. Jira integrations are tenant-wide
|
||||
and do not require unlimited visibility.
|
||||
summary: Get available issue types for a Jira project
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6924,7 +6928,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_retrieve
|
||||
description: Fetch detailed information about a specific integration by its
|
||||
ID.
|
||||
ID. Integrations outside the provider visibility of the role are reported
|
||||
the same way as one that does not exist.
|
||||
summary: Retrieve integration details
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6978,7 +6983,8 @@ paths:
|
||||
patch:
|
||||
operationId: api_v1_integrations_partial_update
|
||||
description: Modify certain fields of an existing integration without affecting
|
||||
other settings.
|
||||
other settings. Integrations attached to providers outside the visibility
|
||||
of the role cannot be modified by it.
|
||||
summary: Partially update an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7013,7 +7019,8 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: api_v1_integrations_destroy
|
||||
description: Remove an integration from the system by its ID.
|
||||
description: Remove an integration from the system by its ID. Integrations attached
|
||||
to providers outside the visibility of the role cannot be deleted by it.
|
||||
summary: Delete an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7033,7 +7040,9 @@ paths:
|
||||
/api/v1/integrations/{id}/connection:
|
||||
post:
|
||||
operationId: api_v1_integrations_connection_create
|
||||
description: Try to verify integration connection
|
||||
description: Try to verify integration connection. Integrations outside the
|
||||
provider visibility of the role are reported the same way as one that does
|
||||
not exist.
|
||||
summary: Check integration connection
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -4,8 +4,11 @@ from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
|
||||
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
|
||||
from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
|
||||
from django.db.utils import ConnectionDoesNotExist
|
||||
from django.urls import reverse
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
from rest_framework.test import APIClient
|
||||
@@ -625,6 +628,34 @@ class TestAPIKeyErrors:
|
||||
assert response.status_code == 401
|
||||
assert "API Key has been revoked." in response.json()["errors"][0]["detail"]
|
||||
|
||||
def test_orphaned_api_key_rejected(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Key whose owning user was deleted returns 401 instead of 500."""
|
||||
client = APIClient()
|
||||
|
||||
api_key = api_keys_fixture[0]
|
||||
# `on_delete=SET_NULL` leaves the key behind with no entity when the owner goes
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert (
|
||||
"No entity matching this api key." in response.json()["errors"][0]["detail"]
|
||||
)
|
||||
|
||||
# The orphaned key is revoked on use; retries fail the regular revoked check
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.revoked is True
|
||||
|
||||
retry_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert retry_response.status_code == 401
|
||||
assert (
|
||||
"API Key has been revoked." in retry_response.json()["errors"][0]["detail"]
|
||||
)
|
||||
|
||||
def test_non_existent_api_key(self, create_test_user, tenants_fixture):
|
||||
"""Key UUID doesn't exist in database."""
|
||||
client = APIClient()
|
||||
@@ -817,6 +848,93 @@ class TestAPIKeyTenantIsolation:
|
||||
error_detail = response_json["errors"][0]["detail"]
|
||||
assert "revoked" in error_detail.lower()
|
||||
|
||||
def test_deleting_user_revokes_api_keys_in_every_tenant(self, tenants_fixture):
|
||||
"""Deleting a user revokes their keys in all their tenants, not just one."""
|
||||
first_tenant, second_tenant = tenants_fixture[0], tenants_fixture[1]
|
||||
|
||||
test_user = User.objects.create_user(
|
||||
name="multi_tenant_user",
|
||||
email="multi_tenant_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
for tenant in (first_tenant, second_tenant):
|
||||
Membership.objects.create(
|
||||
user=test_user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
first_key, _ = TenantAPIKey.objects.create_api_key(
|
||||
name="Key in first tenant", tenant_id=first_tenant.id, entity=test_user
|
||||
)
|
||||
second_key, _ = TenantAPIKey.objects.create_api_key(
|
||||
name="Key in second tenant", tenant_id=second_tenant.id, entity=test_user
|
||||
)
|
||||
|
||||
test_user.delete()
|
||||
|
||||
first_key.refresh_from_db()
|
||||
second_key.refresh_from_db()
|
||||
assert first_key.revoked is True
|
||||
assert second_key.revoked is True
|
||||
# `on_delete=SET_NULL` orphans the keys, so revoking them is what keeps them
|
||||
# from authenticating
|
||||
assert first_key.entity_id is None
|
||||
assert second_key.entity_id is None
|
||||
|
||||
def test_revoke_user_api_keys_uses_the_admin_connection(
|
||||
self, monkeypatch, tenants_fixture
|
||||
):
|
||||
"""The revocation must not go through the default connection.
|
||||
|
||||
`api_keys` is RLS protected and its policy denies every row when `api.tenant_id`
|
||||
is unset, which is the case while a user is deleted through the admin
|
||||
connection: the update would silently revoke nothing and leave usable orphaned
|
||||
keys behind.
|
||||
|
||||
Pointing `admin_db` at a missing alias is the only way to assert the connection
|
||||
here, because the test suite runs on a single superuser database with
|
||||
`MainRouter.admin_db` patched to "default" (see `conftest.py`), so RLS never
|
||||
applies and both connections are otherwise indistinguishable.
|
||||
"""
|
||||
test_user = User.objects.create_user(
|
||||
name="admin_connection_user",
|
||||
email="admin_connection_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(user=test_user, tenant=tenants_fixture[0])
|
||||
TenantAPIKey.objects.create_api_key(
|
||||
name="Key for admin connection check",
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
entity=test_user,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
|
||||
|
||||
with pytest.raises(ConnectionDoesNotExist):
|
||||
revoke_user_api_keys(sender=User, instance=test_user)
|
||||
|
||||
def test_revoke_membership_api_keys_uses_the_admin_connection(
|
||||
self, monkeypatch, tenants_fixture
|
||||
):
|
||||
"""Same as the user deletion case: this receiver also runs as its cascade."""
|
||||
test_user = User.objects.create_user(
|
||||
name="admin_connection_membership_user",
|
||||
email="admin_connection_membership_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
membership = Membership.objects.create(
|
||||
user=test_user, tenant=tenants_fixture[0]
|
||||
)
|
||||
TenantAPIKey.objects.create_api_key(
|
||||
name="Key for membership admin connection check",
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
entity=test_user,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
|
||||
|
||||
with pytest.raises(ConnectionDoesNotExist):
|
||||
revoke_membership_api_keys(sender=Membership, instance=membership)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAPIKeyLifecycle:
|
||||
|
||||
@@ -2,11 +2,18 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from allauth.account import app_settings as account_app_settings
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.core import context
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount import app_settings as socialaccount_app_settings
|
||||
from allauth.socialaccount.internal.flows.login import complete_login
|
||||
from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
from api.adapters import ProwlerSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -40,6 +47,7 @@ def _saml_request(rf, organization_slug):
|
||||
def _saml_sociallogin(user):
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "saml"
|
||||
sociallogin.account.extra_data = {}
|
||||
@@ -48,6 +56,59 @@ def _saml_sociallogin(user):
|
||||
return sociallogin
|
||||
|
||||
|
||||
def _oauth_sociallogin(
|
||||
user,
|
||||
*,
|
||||
provider="google",
|
||||
provider_email_verified=True,
|
||||
include_extra_email=True,
|
||||
):
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = provider
|
||||
sociallogin.account.extra_data = (
|
||||
{"email": user.email} if include_extra_email else {}
|
||||
)
|
||||
sociallogin.email_addresses = [
|
||||
EmailAddress(
|
||||
email=user.email,
|
||||
verified=provider_email_verified,
|
||||
primary=True,
|
||||
)
|
||||
]
|
||||
sociallogin.user = user
|
||||
sociallogin.connect = MagicMock()
|
||||
return sociallogin
|
||||
|
||||
|
||||
def _real_oauth_sociallogin(user, uid):
|
||||
provider = MagicMock()
|
||||
provider.id = "google"
|
||||
provider.app = None
|
||||
provider.get_settings.return_value = {}
|
||||
return SocialLogin(
|
||||
user=user,
|
||||
account=SocialAccount(
|
||||
provider="google",
|
||||
uid=uid,
|
||||
extra_data={"email": user.email},
|
||||
),
|
||||
email_addresses=[EmailAddress(email=user.email, verified=True, primary=True)],
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
def _verify_local_email(user):
|
||||
return EmailAddress.objects.create(
|
||||
user=user,
|
||||
email=user.email,
|
||||
verified=True,
|
||||
primary=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProwlerSocialAccountAdapter:
|
||||
def test_get_user_by_email_returns_user(self, create_test_user):
|
||||
@@ -157,6 +218,7 @@ class TestProwlerSocialAccountAdapter:
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.user = MagicMock()
|
||||
sociallogin.user.email = ""
|
||||
@@ -168,25 +230,119 @@ class TestProwlerSocialAccountAdapter:
|
||||
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_non_saml_links_by_email(self, create_test_user, rf):
|
||||
"""Non-SAML providers (e.g. Google/GitHub) still link to an existing
|
||||
local account by email; the tenant binding only applies to SAML."""
|
||||
def test_pre_social_login_blocks_unverified_local_email(self, create_test_user, rf):
|
||||
"""A verified OAuth email must not claim an unverified local account."""
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "google"
|
||||
sociallogin.account.extra_data = {"email": create_test_user.email}
|
||||
sociallogin.user = create_test_user
|
||||
sociallogin.connect = MagicMock()
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_complete_oauth_login_does_not_link_unverified_local_email(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""Regression test for the complete pre-hijack account-linking flow."""
|
||||
incoming_user = User(email=create_test_user.email)
|
||||
incoming_user.set_unusable_password()
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
incoming_user,
|
||||
uid="victim-google-account",
|
||||
)
|
||||
request = rf.get("/")
|
||||
request.session = {}
|
||||
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
complete_login(request, sociallogin, raises=True)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
assert not SocialAccount.objects.filter(
|
||||
provider="google", uid="victim-google-account"
|
||||
).exists()
|
||||
|
||||
def test_pre_social_login_allows_already_connected_account(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""Existing provider bindings do not need to relink on every login."""
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
sociallogin.account.pk = "existing-social-account"
|
||||
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
call_args = sociallogin.connect.call_args
|
||||
assert call_args is not None
|
||||
_, called_user = call_args[0]
|
||||
assert called_user.email == create_test_user.email
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_blocks_unverified_provider_email(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""An OAuth provider must prove ownership of the matching email."""
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(
|
||||
create_test_user,
|
||||
provider="github",
|
||||
provider_email_verified=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_links_verified_emails(self, create_test_user, rf):
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
request = rf.get("/")
|
||||
|
||||
adapter.pre_social_login(request, sociallogin)
|
||||
|
||||
sociallogin.connect.assert_called_once_with(request, create_test_user)
|
||||
|
||||
def test_verified_social_account_link_does_not_send_notification(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
_verify_local_email(create_test_user)
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
create_test_user,
|
||||
uid="verified-google-account",
|
||||
)
|
||||
|
||||
request = rf.get("/")
|
||||
with context.request_context(request):
|
||||
ProwlerSocialAccountAdapter().pre_social_login(request, sociallogin)
|
||||
|
||||
assert SocialAccount.objects.filter(
|
||||
provider="google",
|
||||
uid="verified-google-account",
|
||||
user=create_test_user,
|
||||
).exists()
|
||||
assert mail.outbox == []
|
||||
|
||||
def test_pre_social_login_uses_verified_email_missing_from_extra_data(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""GitHub can return its verified primary email outside extra_data."""
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(
|
||||
create_test_user,
|
||||
provider="github",
|
||||
include_extra_email=False,
|
||||
)
|
||||
request = rf.get("/")
|
||||
|
||||
adapter.pre_social_login(request, sociallogin)
|
||||
|
||||
sociallogin.connect.assert_called_once_with(request, create_test_user)
|
||||
|
||||
def test_social_account_linking_settings_are_fail_closed(self):
|
||||
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION
|
||||
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION_AUTO_CONNECT
|
||||
assert not account_app_settings.EMAIL_NOTIFICATIONS
|
||||
|
||||
def test_save_user_social_with_invitation_joins_invited_tenant(
|
||||
self, rf, create_test_user, tenants_fixture
|
||||
|
||||
@@ -4,11 +4,17 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication
|
||||
from api.authentication import (
|
||||
OrphanedAPIKeyError,
|
||||
SSEAuthentication,
|
||||
TenantAPIKeyAuthentication,
|
||||
)
|
||||
from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey
|
||||
from django.db import connections
|
||||
from django.db.models.query import QuerySet
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
@@ -38,13 +44,12 @@ class TestTenantAPIKeyAuthentication:
|
||||
request = request_factory.get("/")
|
||||
|
||||
# Call the method
|
||||
entity, auth_dict = auth_backend._authenticate_credentials(
|
||||
request, encrypted_key
|
||||
)
|
||||
validated_key = auth_backend._authenticate_credentials(request, encrypted_key)
|
||||
|
||||
# Verify that the entity is the user associated with the API key
|
||||
assert entity == api_key.entity
|
||||
assert entity.id == api_key.entity.id
|
||||
assert validated_key.id == api_key.id
|
||||
assert validated_key.entity == api_key.entity
|
||||
assert validated_key.entity.id == api_key.entity.id
|
||||
|
||||
def test_authenticate_credentials_restores_manager_on_success(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
@@ -231,6 +236,120 @@ class TestTenantAPIKeyAuthentication:
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_credentials_orphaned_api_key(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test credential validation fails when the owning user no longer exists."""
|
||||
api_key = api_keys_fixture[0]
|
||||
_, encrypted_key = api_key._raw_key.split(TenantAPIKey.objects.separator, 1)
|
||||
|
||||
# `entity` is what `on_delete=SET_NULL` leaves behind when the owner is deleted
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
|
||||
|
||||
request = request_factory.get("/")
|
||||
|
||||
with pytest.raises(OrphanedAPIKeyError):
|
||||
auth_backend._authenticate_credentials(request, encrypted_key)
|
||||
|
||||
# The orphaned key is revoked on use, so it stops showing up as active
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.revoked is True
|
||||
|
||||
def test_authenticate_orphaned_api_key(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test authentication fails with a key whose owning user was deleted.
|
||||
|
||||
Regression test: this used to raise `AttributeError: 'NoneType' object has no
|
||||
attribute 'id'` while building the auth dict, which DRF re-raises as
|
||||
`WrappedAttributeError` and turns into a 500 instead of a 401.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "No entity matching this api key."
|
||||
|
||||
# The orphaned key is revoked on use; retries fail the regular revoked check
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.revoked is True
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test the API key is read a single time and the row is locked.
|
||||
|
||||
Validation, the `last_used_at` update and the claims must all come from the
|
||||
same authoritative row: a second, unlocked lookup would reopen the window
|
||||
where a key revoked in between still authenticates.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
|
||||
|
||||
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key_selects = [
|
||||
query["sql"]
|
||||
for query in captured.captured_queries
|
||||
if query["sql"].startswith("SELECT") and '"api_keys"' in query["sql"]
|
||||
]
|
||||
|
||||
assert len(api_key_selects) == 1
|
||||
assert "FOR UPDATE" in api_key_selects[0]
|
||||
|
||||
def test_authenticate_ignores_revocation_after_the_locked_read(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test the claims describe the row that was validated, not a later state.
|
||||
|
||||
Regression test: the key used to be looked up again to build the auth dict,
|
||||
without rechecking `revoked` or `entity`. A key revoked or orphaned between
|
||||
both reads still authenticated, and the claims came from that stale row. With
|
||||
a single locked read the write below cannot land mid-authentication, and the
|
||||
revocation only takes effect on the next request.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
entity_at_validation = api_key.entity
|
||||
original_save = TenantAPIKey.save
|
||||
|
||||
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
|
||||
# Runs after validation, right before the claims are built: the exact
|
||||
# window a concurrent revocation or user deletion used to slip into
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
|
||||
return original_save(instance, *args, **kwargs)
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
|
||||
|
||||
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
|
||||
entity, auth_dict = auth_backend.authenticate(request)
|
||||
|
||||
assert entity == entity_at_validation
|
||||
assert auth_dict["sub"] == str(entity_at_validation.id)
|
||||
assert auth_dict["tenant_id"] == str(api_key.tenant_id)
|
||||
assert auth_dict["api_key_prefix"] == api_key.prefix
|
||||
|
||||
# The revoked key is rejected from the next request on
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_expired_api_key(
|
||||
self, auth_backend, create_test_user, tenants_fixture, request_factory
|
||||
):
|
||||
|
||||
@@ -2,11 +2,12 @@ import uuid
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY
|
||||
from api.decorators import handle_provider_deletion, set_tenant
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import DatabaseError, IntegrityError
|
||||
from django.db import DEFAULT_DB_ALIAS, DatabaseError, IntegrityError
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -204,6 +205,106 @@ class TestHandleProviderDeletionDecorator:
|
||||
with pytest.raises(DatabaseError):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=str(provider.id))
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_provider_missing_or_soft_deleted(
|
||||
self, mock_provider_filter, mock_rls, tenants_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_tenant_missing(
|
||||
self, mock_provider_filter, mock_tenant_filter, mock_rls, tenants_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Membership.objects.filter")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_tenant_without_memberships(
|
||||
self,
|
||||
mock_provider_filter,
|
||||
mock_tenant_filter,
|
||||
mock_membership_filter,
|
||||
mock_rls,
|
||||
tenants_fixture,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = True
|
||||
mock_membership_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Membership.objects.filter")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_active_provider_and_tenant_reraises(
|
||||
self,
|
||||
mock_provider_filter,
|
||||
mock_tenant_filter,
|
||||
mock_membership_filter,
|
||||
mock_rls,
|
||||
tenants_fixture,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
graph_error = GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = True
|
||||
mock_membership_filter.return_value.exists.return_value = True
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise graph_error
|
||||
|
||||
with pytest.raises(GraphDatabaseQueryException) as exc_info:
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
assert exc_info.value is graph_error
|
||||
mock_rls.assert_called_once_with(str(tenant.id), using=DEFAULT_DB_ALIAS)
|
||||
|
||||
def test_missing_provider_and_scan_raises_assertion(self, tenants_fixture):
|
||||
"""Raises AssertionError when neither provider_id nor scan_id in kwargs."""
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import pytest
|
||||
from api.models import (
|
||||
Integration,
|
||||
IntegrationProviderRelationship,
|
||||
Membership,
|
||||
ProviderGroup,
|
||||
ProviderGroupMembership,
|
||||
@@ -681,6 +683,363 @@ class TestLimitedVisibility:
|
||||
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def jira_integration(self, tenants_fixture):
|
||||
# Jira is a tenant-wide integration: it is not attached to any provider
|
||||
return Integration.objects.create(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
enabled=True,
|
||||
connected=True,
|
||||
integration_type=Integration.IntegrationChoices.JIRA,
|
||||
configuration={"projects": {"TEST": "Test project"}},
|
||||
credentials={
|
||||
"domain": "test",
|
||||
"user_mail": "a@b.com",
|
||||
"api_token": "token",
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def out_of_scope_integration(self, tenants_fixture, provider_factory):
|
||||
tenant_id = tenants_fixture[0].id
|
||||
integration = Integration.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
enabled=True,
|
||||
connected=True,
|
||||
integration_type=Integration.IntegrationChoices.AMAZON_S3,
|
||||
configuration={
|
||||
"bucket_name": "bucket",
|
||||
"output_directory": "output",
|
||||
},
|
||||
credentials={"aws_access_key_id": "key"},
|
||||
)
|
||||
IntegrationProviderRelationship.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=integration,
|
||||
provider=provider_factory(),
|
||||
)
|
||||
return integration
|
||||
|
||||
def test_integrations_list_includes_tenant_wide_integration(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
jira_integration,
|
||||
aws_provider_pair,
|
||||
):
|
||||
# Integration 2 is attached to both providers, so make both visible to the role
|
||||
# to assert the provider join does not duplicate it in the listing
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=aws_provider_pair[1].tenant_id,
|
||||
provider=aws_provider_pair[1],
|
||||
provider_group=ProviderGroup.objects.get(name="limited_visibility_group"),
|
||||
)
|
||||
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
# The tenant-wide Jira integration is visible without unlimited visibility
|
||||
assert str(jira_integration.id) in integration_ids
|
||||
# Integrations attached to more than one visible provider are not duplicated
|
||||
assert integration_ids.count(str(integrations_fixture[1].id)) == 1
|
||||
assert response.json()["meta"]["pagination"]["count"] == len(integration_ids)
|
||||
|
||||
def test_integrations_list_without_provider_groups_keeps_tenant_wide_integration(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
|
||||
):
|
||||
# A role with no provider group at all sees no provider, but still needs Jira
|
||||
RoleProviderGroupRelationship.objects.all().delete()
|
||||
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
assert integration_ids == [str(jira_integration.id)]
|
||||
|
||||
def test_integrations_include_providers_hides_out_of_scope_providers(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, aws_provider_pair
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-list"), {"include": "providers"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
included_ids = {item["id"] for item in response.json().get("included", [])}
|
||||
assert str(aws_provider_pair[0].id) in included_ids
|
||||
# Sideloaded resources must not disclose the provider the role cannot see
|
||||
assert str(hidden_provider.id) not in included_ids
|
||||
|
||||
def test_integrations_list_with_sparse_fields(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-list"), {"fields[integrations]": "enabled"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert all(
|
||||
list(item["attributes"].keys()) == ["enabled"]
|
||||
for item in response.json()["data"]
|
||||
)
|
||||
|
||||
def test_integrations_list_excludes_out_of_scope_integration(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
assert str(out_of_scope_integration.id) not in integration_ids
|
||||
|
||||
def test_integration_detail_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-detail", kwargs={"pk": out_of_scope_integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_integration_connection_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-connection", kwargs={"pk": out_of_scope_integration.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_integration_update_allowed_when_fully_visible(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
|
||||
):
|
||||
# Integration 1 is only related to provider1, which the role can access
|
||||
integration = integrations_fixture[0]
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(integration.id),
|
||||
"attributes": {
|
||||
"enabled": False,
|
||||
# integration_type is `amazon_s3`
|
||||
"credentials": {"aws_access_key_id": "new_value"},
|
||||
"configuration": {
|
||||
"bucket_name": "new_bucket_name",
|
||||
"output_directory": "new_output_directory",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration.refresh_from_db()
|
||||
assert integration.enabled is False
|
||||
|
||||
# Tenant-wide integrations have no provider restricting the role
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(jira_integration.id),
|
||||
"attributes": {"enabled": False},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": jira_integration.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
jira_integration.refresh_from_db()
|
||||
assert jira_integration.enabled is False
|
||||
|
||||
def test_integration_create_rejects_out_of_scope_provider(
|
||||
self, authenticated_client_rbac_limited, aws_provider_pair
|
||||
):
|
||||
# provider2 is not in any provider group assigned to the role
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": "amazon_s3",
|
||||
"configuration": {
|
||||
"bucket_name": "attacker_bucket",
|
||||
"output_directory": "output",
|
||||
},
|
||||
"credentials": {"aws_access_key_id": "key"},
|
||||
},
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{"type": "providers", "id": str(aws_provider_pair[1].id)}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not Integration.objects.filter(
|
||||
integrationproviderrelationship__provider=aws_provider_pair[1],
|
||||
configuration__bucket_name="attacker_bucket",
|
||||
).exists()
|
||||
|
||||
@pytest.mark.parametrize("submitted_providers", [True, False])
|
||||
def test_integration_update_denied_when_shared_with_hidden_provider(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
aws_provider_pair,
|
||||
submitted_providers,
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible).
|
||||
# Editing it would reach beyond the visibility of the role, just like deleting
|
||||
# it, so both are rejected consistently
|
||||
integration = integrations_fixture[1]
|
||||
visible_provider, hidden_provider = aws_provider_pair
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(integration.id),
|
||||
"attributes": {
|
||||
"enabled": False,
|
||||
# integration_type is `amazon_s3`
|
||||
"credentials": {"aws_access_key_id": "new_value"},
|
||||
"configuration": {
|
||||
"bucket_name": "new_bucket_name",
|
||||
"output_directory": "new_output_directory",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if submitted_providers:
|
||||
payload["data"]["relationships"] = {
|
||||
"providers": {
|
||||
"data": [{"type": "providers", "id": str(visible_provider.id)}]
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
integration.refresh_from_db()
|
||||
assert integration.enabled is True
|
||||
assert integration.providers.filter(id=hidden_provider.id).exists()
|
||||
assert integration.providers.filter(id=visible_provider.id).exists()
|
||||
|
||||
def test_integration_delete_denied_when_shared_with_hidden_provider(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
|
||||
integration = integrations_fixture[1]
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Integration.objects.filter(id=integration.id).exists()
|
||||
|
||||
def test_integration_delete_allowed_when_fully_visible(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
|
||||
):
|
||||
# Integration 1 is only related to provider1, which the role can access
|
||||
integration = integrations_fixture[0]
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Integration.objects.filter(id=integration.id).exists()
|
||||
|
||||
# Tenant-wide integrations have no provider restricting the role
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": jira_integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
def test_jira_issue_types_allowed_without_unlimited_visibility(
|
||||
self, authenticated_client_rbac_limited, jira_integration
|
||||
):
|
||||
with patch("api.v1.views.initialize_prowler_integration") as mock_jira:
|
||||
mock_jira.return_value.get_available_issue_types.return_value = ["Task"]
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse(
|
||||
"integration-jira-issue-types",
|
||||
kwargs={"integration_pk": jira_integration.id},
|
||||
),
|
||||
{"project_key": "TEST"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["issue_types"] == ["Task"]
|
||||
|
||||
def test_jira_issue_types_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse(
|
||||
"integration-jira-issue-types",
|
||||
kwargs={"integration_pk": out_of_scope_integration.id},
|
||||
),
|
||||
{"project_key": "TEST"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_jira_dispatches_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-jira-dispatches",
|
||||
kwargs={"integration_pk": out_of_scope_integration.id},
|
||||
),
|
||||
data=json.dumps({}),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_jira_dispatches_allowed_without_unlimited_visibility(
|
||||
self, authenticated_client_rbac_limited, jira_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-jira-dispatches",
|
||||
kwargs={"integration_pk": jira_integration.id},
|
||||
),
|
||||
data=json.dumps({}),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
# The integration is reachable: the request fails on payload validation, not RBAC
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overviews_providers(
|
||||
self,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
|
||||
from neo4j.exceptions import ServiceUnavailable
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class TestRetryableSession:
|
||||
max_retries=3,
|
||||
retry_if=lambda exc: exc is retryable_error,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
assert session.execute_write(work) == "success"
|
||||
@@ -54,6 +55,7 @@ class TestRetryableSession:
|
||||
max_retries=3,
|
||||
retry_if=lambda _: False,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
@@ -83,3 +85,81 @@ class TestRetryableSession:
|
||||
driver_sessions[0].close.assert_called_once_with()
|
||||
driver_sessions[1].close.assert_called_once_with()
|
||||
driver_sessions[2].close.assert_not_called()
|
||||
|
||||
def test_retry_exhaustion_with_context_reports_attempts_and_elapsed_time(self):
|
||||
error = RuntimeError("still retryable")
|
||||
driver_sessions = [MagicMock() for _ in range(3)]
|
||||
for driver_session in driver_sessions:
|
||||
driver_session.execute_write.side_effect = error
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(side_effect=driver_sessions),
|
||||
max_retries=2,
|
||||
retry_if=lambda _: True,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.attack_paths.retryable_session.time.monotonic",
|
||||
side_effect=[100.0, 127.1234],
|
||||
),
|
||||
pytest.raises(RetryExhaustedError) as exc_info,
|
||||
):
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.method_name == "execute_write"
|
||||
assert exc_info.value.attempts == 3
|
||||
assert exc_info.value.elapsed_seconds == pytest.approx(27.1234)
|
||||
assert exc_info.value.last_error is error
|
||||
assert exc_info.value.__cause__ is error
|
||||
assert str(exc_info.value) == (
|
||||
"Neptune write execute_write failed after 3 attempts over 27.123s. "
|
||||
"Last error: still retryable"
|
||||
)
|
||||
|
||||
def test_retry_exhaustion_with_zero_retries_reports_one_attempt(self):
|
||||
error = ServiceUnavailable("still unavailable")
|
||||
driver_session = MagicMock()
|
||||
driver_session.execute_write.side_effect = error
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(return_value=driver_session),
|
||||
max_retries=0,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with pytest.raises(RetryExhaustedError) as exc_info:
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.attempts == 1
|
||||
|
||||
@patch("api.attack_paths.retryable_session.time.sleep")
|
||||
@patch("api.attack_paths.retryable_session.random.uniform", return_value=3.0)
|
||||
def test_contextual_retry_warning_includes_original_error(
|
||||
self, _mock_uniform, _mock_sleep
|
||||
):
|
||||
error = RuntimeError("retryable detail")
|
||||
first_session = MagicMock()
|
||||
first_session.execute_write.side_effect = error
|
||||
second_session = MagicMock()
|
||||
second_session.execute_write.return_value = "success"
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(side_effect=[first_session, second_session]),
|
||||
max_retries=1,
|
||||
retry_if=lambda _: True,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with patch("api.attack_paths.retryable_session.logger.warning") as mock_warning:
|
||||
assert session.execute_write(MagicMock()) == "success"
|
||||
|
||||
mock_warning.assert_called_once_with(
|
||||
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
|
||||
"Neptune write",
|
||||
"execute_write",
|
||||
"RuntimeError",
|
||||
"retryable detail",
|
||||
1,
|
||||
1,
|
||||
3.0,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from config.settings import sentry as sentry_settings
|
||||
from config.settings.sentry import before_send
|
||||
|
||||
@@ -82,6 +83,45 @@ def test_before_send_passes_through_non_ignored_log():
|
||||
assert result == event
|
||||
|
||||
|
||||
def test_before_send_ignores_cartography_missing_temporary_database_log():
|
||||
log_record = _make_log_record(
|
||||
msg="Cartography job failed with %s for database %s",
|
||||
name="cartography.graph.job",
|
||||
args=(
|
||||
"Neo.ClientError.Database.DatabaseNotFound",
|
||||
"db-tmp-scan-12345678",
|
||||
),
|
||||
)
|
||||
|
||||
event = MagicMock()
|
||||
|
||||
assert before_send(event, {"log_record": log_record}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("logger_name", "message"),
|
||||
[
|
||||
(
|
||||
"cartography.graph.job.worker",
|
||||
"Neo.ClientError.Database.DatabaseNotFound for db-tmp-scan-12345678",
|
||||
),
|
||||
(
|
||||
"cartography.graph.job",
|
||||
"DatabaseNotFound for db-tmp-scan-12345678",
|
||||
),
|
||||
(
|
||||
"cartography.graph.job",
|
||||
"Neo.ClientError.Database.DatabaseNotFound for db-tenant-12345678",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_before_send_passes_through_similar_cartography_logs(logger_name, message):
|
||||
log_record = _make_log_record(msg=message, name=logger_name)
|
||||
event = MagicMock()
|
||||
|
||||
assert before_send(event, {"log_record": log_record}) is event
|
||||
|
||||
|
||||
def test_before_send_passes_through_non_ignored_exception():
|
||||
"""Test that before_send passes through exceptions that don't contain ignored exceptions."""
|
||||
exc_info = (Exception, Exception("Some other error message"), None)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import pytest
|
||||
from api.v1.serializer_utils.integrations import S3ConfigSerializer
|
||||
from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret
|
||||
from api.v1.serializer_utils.integrations import (
|
||||
JiraCredentialSerializer,
|
||||
S3ConfigSerializer,
|
||||
)
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.v1.serializers import (
|
||||
ImageProviderSecret,
|
||||
KubernetesProviderSecret,
|
||||
OracleCloudProviderSecret,
|
||||
)
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -100,6 +108,59 @@ class TestS3ConfigSerializer:
|
||||
assert "output_directory" in serializer.errors
|
||||
|
||||
|
||||
class TestJiraCredentialSerializer:
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"a",
|
||||
"prowler",
|
||||
"prowler-domain",
|
||||
"A1-b2-C3",
|
||||
"a" * 63,
|
||||
),
|
||||
)
|
||||
def test_valid_site_name(self, domain):
|
||||
serializer = JiraCredentialSerializer(
|
||||
data={
|
||||
"user_mail": "testing@prowler.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": domain,
|
||||
}
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"169.254.169.254#",
|
||||
"internal/service",
|
||||
"internal?target",
|
||||
"internal\\target",
|
||||
"internal:8000",
|
||||
"user@internal",
|
||||
"example.atlassian.net",
|
||||
"-prowler",
|
||||
"prowler-",
|
||||
"a" * 64,
|
||||
" prowler",
|
||||
"prowler ",
|
||||
"prowler\n",
|
||||
),
|
||||
)
|
||||
def test_invalid_site_name(self, domain):
|
||||
serializer = JiraCredentialSerializer(
|
||||
data={
|
||||
"user_mail": "testing@prowler.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": domain,
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "domain" in serializer.errors
|
||||
|
||||
|
||||
class TestImageProviderSecret:
|
||||
"""Test cases for ImageProviderSecret validation."""
|
||||
|
||||
@@ -134,6 +195,64 @@ class TestImageProviderSecret:
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
|
||||
class TestOracleCloudProviderSecret:
|
||||
def valid_secret(self, **overrides):
|
||||
secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
}
|
||||
secret.update(overrides)
|
||||
return secret
|
||||
|
||||
def test_accepts_regionless_secret(self):
|
||||
serializer = OracleCloudProviderSecret(data=self.valid_secret())
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
def test_accepts_and_ignores_region_field(self):
|
||||
secret = self.valid_secret(region="us-phoenix-1")
|
||||
serializer = OracleCloudProviderSecret(data=secret)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_field, legacy_value",
|
||||
[
|
||||
("region", None),
|
||||
("region", ""),
|
||||
("region", {"name": "us-ashburn-1"}),
|
||||
],
|
||||
)
|
||||
def test_accepts_and_ignores_any_legacy_region_value(
|
||||
self, legacy_field, legacy_value
|
||||
):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(**{legacy_field: legacy_value})
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert legacy_field not in serializer.validated_data
|
||||
|
||||
|
||||
class TestProviderSecretFieldSchema:
|
||||
def test_oraclecloud_schema_includes_legacy_region_field(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
oraclecloud_schema = next(
|
||||
credential_schema
|
||||
for credential_schema in schema["oneOf"]
|
||||
if credential_schema["title"]
|
||||
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
|
||||
)
|
||||
|
||||
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
|
||||
|
||||
|
||||
class TestKubernetesProviderSecret:
|
||||
def test_valid_static_kubeconfig_is_accepted(self):
|
||||
kubeconfig_content = """
|
||||
@@ -190,6 +309,36 @@ current-context: test-context
|
||||
assert not serializer.is_valid()
|
||||
assert "kubeconfig_content" in serializer.errors
|
||||
|
||||
def test_kubeconfig_with_auth_provider_cmd_path_is_rejected(self):
|
||||
kubeconfig_content = """
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: test-cluster
|
||||
cluster:
|
||||
server: https://kubernetes.example.test
|
||||
users:
|
||||
- name: test-user
|
||||
user:
|
||||
auth-provider:
|
||||
name: gcp
|
||||
config:
|
||||
cmd-path: /bin/sh
|
||||
contexts:
|
||||
- name: test-context
|
||||
context:
|
||||
cluster: test-cluster
|
||||
user: test-user
|
||||
current-context: test-context
|
||||
"""
|
||||
|
||||
serializer = KubernetesProviderSecret(
|
||||
data={"kubeconfig_content": kubeconfig_content}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "kubeconfig_content" in serializer.errors
|
||||
|
||||
def test_malformed_kubeconfig_is_rejected(self):
|
||||
serializer = KubernetesProviderSecret(
|
||||
data={"kubeconfig_content": "apiVersion: ["}
|
||||
|
||||
@@ -11,7 +11,11 @@ from unittest.mock import MagicMock, patch
|
||||
import neo4j
|
||||
import pytest
|
||||
from api.attack_paths import sink as sink_module
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.attack_paths.database import (
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
)
|
||||
from api.attack_paths.retryable_session import RetryExhaustedError
|
||||
from api.attack_paths.sink import factory
|
||||
from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink
|
||||
from api.attack_paths.sink.neptune import (
|
||||
@@ -123,6 +127,14 @@ class TestSinkFactory:
|
||||
assert mock_driver.call_count == 1
|
||||
|
||||
|
||||
def test_neo4j_sync_batch_size_defaults_to_1000():
|
||||
assert Neo4jSink.sync_batch_size == 1000
|
||||
|
||||
|
||||
def test_neptune_sync_batch_size_defaults_to_500():
|
||||
assert NeptuneSink.sync_batch_size == 500
|
||||
|
||||
|
||||
class TestGetBackendForScan:
|
||||
"""``get_backend_for_scan`` routes by the row's recorded sink backend."""
|
||||
|
||||
@@ -372,6 +384,7 @@ class TestNeptuneRetryPolicy:
|
||||
assert (
|
||||
kwargs["initial_retry_delay_seconds"] == NEPTUNE_WRITE_RETRY_DELAY_SECONDS
|
||||
)
|
||||
assert kwargs["retry_context"] == "Neptune write"
|
||||
|
||||
@patch("api.attack_paths.sink.neptune.RetryableSession")
|
||||
def test_reader_session_does_not_enable_write_retry_policy(self, retryable_session):
|
||||
@@ -384,6 +397,48 @@ class TestNeptuneRetryPolicy:
|
||||
kwargs = retryable_session.call_args.kwargs
|
||||
assert kwargs["retry_if"] is None
|
||||
assert kwargs["initial_retry_delay_seconds"] == 0
|
||||
assert kwargs["retry_context"] is None
|
||||
|
||||
def test_writer_retry_exhaustion_preserves_neptune_error_details(self):
|
||||
message = (
|
||||
"Unexpected server exception 'Operation failed due to conflicting "
|
||||
"concurrent operations (please retry), 0 transactions are currently "
|
||||
"rolling back.'"
|
||||
)
|
||||
error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
|
||||
code="BoltProtocol.unexpectedException",
|
||||
message=message,
|
||||
)
|
||||
retry_error = RetryExhaustedError(
|
||||
retry_context="Neptune write",
|
||||
method_name="execute_write",
|
||||
attempts=4,
|
||||
elapsed_seconds=27.1234,
|
||||
last_error=error,
|
||||
)
|
||||
sink = NeptuneSink()
|
||||
driver = MagicMock()
|
||||
retryable_session = MagicMock()
|
||||
retryable_session.execute_write.side_effect = retry_error
|
||||
|
||||
with (
|
||||
patch.object(sink, "_get_writer", return_value=driver),
|
||||
patch(
|
||||
"api.attack_paths.sink.neptune.RetryableSession",
|
||||
return_value=retryable_session,
|
||||
),
|
||||
pytest.raises(NeptuneWriteRetryExhaustedException) as exc_info,
|
||||
):
|
||||
with sink.get_session() as session:
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.code == "BoltProtocol.unexpectedException"
|
||||
assert str(exc_info.value) == (
|
||||
"BoltProtocol.unexpectedException: Neptune write execute_write failed "
|
||||
"after 4 attempts over 27.123s. Last error: "
|
||||
f"{message}"
|
||||
)
|
||||
assert exc_info.value.__cause__ is error
|
||||
|
||||
|
||||
class TestNeptuneSinkDropSubgraph:
|
||||
|
||||
@@ -171,6 +171,53 @@ class TestInitializeProwlerProvider:
|
||||
key="value", mutelist_content={"key": "value"}
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_removes_region_string(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"region": "us-ashburn-1",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
initialize_prowler_provider(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.assert_called_once_with(
|
||||
user="ocid1.user.oc1..fake",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_without_region_omits_scan_filter(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
initialize_prowler_provider(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.assert_called_once_with(
|
||||
user="ocid1.user.oc1..fake",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
)
|
||||
|
||||
|
||||
class TestProwlerProviderConnectionTest:
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
@@ -185,6 +232,37 @@ class TestProwlerProviderConnectionTest:
|
||||
key="value", provider_id="1234567890", raise_on_exception=False
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_oraclecloud_connection_test_uses_direct_credentials_without_region(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
prowler_provider_connection_test(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
|
||||
user="ocid1.user.oc1..aaaaaaaexample",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
region=getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
),
|
||||
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_without_secret(
|
||||
@@ -356,7 +434,7 @@ class TestGetProwlerProviderKwargs:
|
||||
expected_result = {**secret_dict, **expected_extra_kwargs}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set(
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
|
||||
self,
|
||||
):
|
||||
secret_dict = {
|
||||
@@ -377,8 +455,13 @@ class TestGetProwlerProviderKwargs:
|
||||
|
||||
result = get_prowler_provider_kwargs(provider)
|
||||
|
||||
expected_result = {**secret_dict, "region": {"us-ashburn-1"}}
|
||||
assert result == expected_result
|
||||
assert result == {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"pass_phrase": "fake-passphrase",
|
||||
}
|
||||
|
||||
def test_get_prowler_provider_kwargs_with_mutelist(self):
|
||||
provider_uid = "provider_uid"
|
||||
@@ -856,7 +939,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "test_api_token",
|
||||
"domain": "example.atlassian.net",
|
||||
"domain": "example",
|
||||
}
|
||||
integration.configuration = {}
|
||||
|
||||
@@ -884,7 +967,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
mock_jira_class.test_connection.assert_called_once_with(
|
||||
user_mail="test@example.com",
|
||||
api_token="test_api_token",
|
||||
domain="example.atlassian.net",
|
||||
domain="example",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@@ -917,7 +1000,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "invalid@example.com",
|
||||
"api_token": "invalid_token",
|
||||
"domain": "invalid.atlassian.net",
|
||||
"domain": "invalid",
|
||||
}
|
||||
integration.configuration = {}
|
||||
|
||||
@@ -942,7 +1025,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
mock_jira_class.test_connection.assert_called_once_with(
|
||||
user_mail="invalid@example.com",
|
||||
api_token="invalid_token",
|
||||
domain="invalid.atlassian.net",
|
||||
domain="invalid",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@@ -970,7 +1053,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "test_api_token",
|
||||
"domain": "example.atlassian.net",
|
||||
"domain": "example",
|
||||
}
|
||||
integration.configuration = {
|
||||
"issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration
|
||||
|
||||
@@ -2917,6 +2917,48 @@ class TestProviderGroupViewSet:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProviderSecretViewSet:
|
||||
@staticmethod
|
||||
def _oraclecloud_secret(**overrides):
|
||||
secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "test-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
}
|
||||
secret.update(overrides)
|
||||
return secret
|
||||
|
||||
def _create_oraclecloud_secret(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
secret,
|
||||
name="OCI Secret",
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": name,
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": secret,
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(oraclecloud_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
return authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
|
||||
response = authenticated_client.get(reverse("providersecret-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -3076,7 +3118,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
},
|
||||
),
|
||||
# OCI with API key credentials (with key_file)
|
||||
@@ -3088,7 +3129,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_file": "/path/to/oci_api_key.pem",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
},
|
||||
),
|
||||
# OCI with API key credentials (with passphrase)
|
||||
@@ -3100,7 +3140,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
"pass_phrase": "my-secure-passphrase",
|
||||
},
|
||||
),
|
||||
@@ -3258,6 +3297,103 @@ current-context: test-context
|
||||
== data["data"]["relationships"]["provider"]["data"]["id"]
|
||||
)
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(
|
||||
key_content=" test-key-content ", region=" us-ashburn-1 "
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert provider_secret.secret["key_content"] == "test-key-content"
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
create_response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {"secret": self._oraclecloud_secret()},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
create_response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes, error_code, error_pointer",
|
||||
(
|
||||
@@ -13423,6 +13559,45 @@ class TestIntegrationViewSet:
|
||||
)
|
||||
assert "credentials" not in response.json()["data"]["attributes"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"169.254.169.254#",
|
||||
"internal/service",
|
||||
"internal?target",
|
||||
"internal\\target",
|
||||
"internal:8000",
|
||||
"user@internal",
|
||||
),
|
||||
)
|
||||
def test_integrations_create_jira_rejects_invalid_domain(
|
||||
self, authenticated_client, domain
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": Integration.IntegrationChoices.JIRA,
|
||||
"configuration": {},
|
||||
"credentials": {
|
||||
"domain": domain,
|
||||
"api_token": "fake-api-token",
|
||||
"user_mail": "testing@prowler.com",
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert Integration.objects.count() == 0
|
||||
|
||||
def test_integrations_create_valid_relationships(
|
||||
self,
|
||||
authenticated_client,
|
||||
@@ -13963,6 +14138,55 @@ class TestIntegrationViewSet:
|
||||
assert "projects" in configuration
|
||||
assert "issue_types" in configuration
|
||||
|
||||
def test_integrations_update_jira_rejects_invalid_domain(
|
||||
self, authenticated_client
|
||||
):
|
||||
create_data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": Integration.IntegrationChoices.JIRA,
|
||||
"configuration": {},
|
||||
"credentials": {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": "original-domain",
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
create_response = authenticated_client.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(create_data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
integration_id = create_response.json()["data"]["id"]
|
||||
|
||||
update_data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": integration_id,
|
||||
"attributes": {
|
||||
"credentials": {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": "169.254.169.254#",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration_id}),
|
||||
data=json.dumps(update_data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
integration = Integration.objects.get(id=integration_id)
|
||||
assert integration.credentials["domain"] == "original-domain"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLTokenValidation:
|
||||
@@ -15524,6 +15748,23 @@ class TestTenantApiKeyViewSet:
|
||||
data = response.json()["data"]
|
||||
assert len(data) == len(api_keys_fixture)
|
||||
|
||||
def test_api_keys_list_with_orphaned_key(
|
||||
self, authenticated_client, api_keys_fixture
|
||||
):
|
||||
"""Test listing keys whose owner was deleted: `entity` is serialized as null."""
|
||||
orphaned_key = api_keys_fixture[0]
|
||||
TenantAPIKey.objects.filter(id=orphaned_key.id).update(entity=None)
|
||||
|
||||
response = authenticated_client.get(reverse("api-key-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == len(api_keys_fixture)
|
||||
serialized_key = next(
|
||||
item for item in data if item["id"] == str(orphaned_key.id)
|
||||
)
|
||||
assert serialized_key["relationships"]["entity"]["data"] is None
|
||||
|
||||
def test_api_keys_list_empty(self, authenticated_client, tenants_fixture):
|
||||
"""Test listing API keys when none exist returns empty list."""
|
||||
response = authenticated_client.get(reverse("api-key-list"))
|
||||
|
||||
@@ -252,12 +252,6 @@ def get_prowler_provider_kwargs(
|
||||
**prowler_provider_kwargs,
|
||||
"filter_accounts": [provider.uid],
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
if isinstance(prowler_provider_kwargs.get("region"), str):
|
||||
prowler_provider_kwargs = {
|
||||
**prowler_provider_kwargs,
|
||||
"region": {prowler_provider_kwargs["region"]},
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.OPENSTACK.value:
|
||||
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
|
||||
# in the provider itself, so it's not needed here.
|
||||
@@ -288,6 +282,11 @@ def get_prowler_provider_kwargs(
|
||||
**{k: v for k, v in prowler_provider_kwargs.items() if v},
|
||||
}
|
||||
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
prowler_provider_kwargs = _normalize_oraclecloud_provider_kwargs(
|
||||
prowler_provider_kwargs
|
||||
)
|
||||
|
||||
if mutelist_processor:
|
||||
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
|
||||
# IaC and Image providers don't support mutelist (both use Trivy's built-in logic)
|
||||
@@ -300,6 +299,40 @@ def get_prowler_provider_kwargs(
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into SDK provider kwargs."""
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into test_connection kwargs."""
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
if (
|
||||
prowler_provider_kwargs.get("user")
|
||||
and prowler_provider_kwargs.get("fingerprint")
|
||||
and prowler_provider_kwargs.get("tenancy")
|
||||
and (
|
||||
prowler_provider_kwargs.get("key_content")
|
||||
or prowler_provider_kwargs.get("key_file")
|
||||
)
|
||||
):
|
||||
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
|
||||
prowler_provider_kwargs["region"] = getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def initialize_prowler_provider(
|
||||
provider: Provider,
|
||||
mutelist_processor: Processor | None = None,
|
||||
@@ -402,6 +435,15 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
|
||||
if prowler_provider_kwargs.get("registry_token"):
|
||||
image_kwargs["registry_token"] = prowler_provider_kwargs["registry_token"]
|
||||
return prowler_provider.test_connection(**image_kwargs)
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
oraclecloud_kwargs = _normalize_oraclecloud_connection_test_kwargs(
|
||||
prowler_provider_kwargs
|
||||
)
|
||||
return prowler_provider.test_connection(
|
||||
**oraclecloud_kwargs,
|
||||
provider_id=provider.uid,
|
||||
raise_on_exception=False,
|
||||
)
|
||||
else:
|
||||
return prowler_provider.test_connection(
|
||||
**prowler_provider_kwargs,
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from api.models import Integration, IntegrationProviderRelationship, Provider
|
||||
from api.v1.serializer_utils.base import BaseValidateSerializer
|
||||
from django.db import transaction
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework_json_api import serializers
|
||||
|
||||
ATLASSIAN_SITE_NAME_REGEX = re.compile(
|
||||
r"\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\Z"
|
||||
)
|
||||
|
||||
|
||||
def replace_integration_providers(
|
||||
integration: Integration, providers: list[Provider], tenant_id: str
|
||||
) -> None:
|
||||
"""Replace the provider relationships of an integration with the given set."""
|
||||
# Atomic on its own, so callers without an ambient transaction cannot leave the
|
||||
# integration with no relationships if the recreation fails halfway
|
||||
with transaction.atomic():
|
||||
IntegrationProviderRelationship.objects.filter(integration=integration).delete()
|
||||
IntegrationProviderRelationship.objects.bulk_create(
|
||||
[
|
||||
IntegrationProviderRelationship(
|
||||
integration=integration, provider=provider, tenant_id=tenant_id
|
||||
)
|
||||
for provider in providers
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class S3ConfigSerializer(BaseValidateSerializer):
|
||||
bucket_name = serializers.CharField()
|
||||
@@ -97,7 +121,17 @@ class AWSCredentialSerializer(BaseValidateSerializer):
|
||||
class JiraCredentialSerializer(BaseValidateSerializer):
|
||||
user_mail = serializers.EmailField(required=True)
|
||||
api_token = serializers.CharField(required=True)
|
||||
domain = serializers.CharField(required=True)
|
||||
domain = serializers.RegexField(
|
||||
regex=ATLASSIAN_SITE_NAME_REGEX,
|
||||
required=True,
|
||||
trim_whitespace=False,
|
||||
error_messages={
|
||||
"invalid": (
|
||||
"Domain must be a valid Atlassian site name containing only "
|
||||
"letters, numbers, and hyphens."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
resource_name = "integrations"
|
||||
@@ -170,7 +204,10 @@ class JiraCredentialSerializer(BaseValidateSerializer):
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
|
||||
"description": "The Jira site name without the '.atlassian.net' suffix (e.g., 'your-domain').",
|
||||
"minLength": 1,
|
||||
"maxLength": 63,
|
||||
"pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$",
|
||||
},
|
||||
},
|
||||
"required": ["user_mail", "api_token", "domain"],
|
||||
|
||||
@@ -214,7 +214,7 @@ from rest_framework_json_api import serializers
|
||||
"kubeconfig_content": {
|
||||
"type": "string",
|
||||
"description": "The content of the Kubernetes kubeconfig file, encoded as a string. "
|
||||
"Kubeconfig exec authentication is not supported in Prowler Cloud for security reasons.",
|
||||
"Kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.",
|
||||
}
|
||||
},
|
||||
"required": ["kubeconfig_content"],
|
||||
@@ -295,16 +295,21 @@ from rest_framework_json_api import serializers
|
||||
"type": "string",
|
||||
"description": "The OCID of the tenancy.",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).",
|
||||
},
|
||||
"pass_phrase": {
|
||||
"type": "string",
|
||||
"description": "The passphrase for the private key, if encrypted.",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"deprecated": True,
|
||||
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
|
||||
},
|
||||
},
|
||||
"required": ["user", "fingerprint", "tenancy", "region"],
|
||||
"required": ["user", "fingerprint", "tenancy"],
|
||||
"anyOf": [
|
||||
{"required": ["key_file"]},
|
||||
{"required": ["key_content"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
|
||||
@@ -47,6 +47,7 @@ from api.v1.serializer_utils.integrations import (
|
||||
JiraCredentialSerializer,
|
||||
S3ConfigSerializer,
|
||||
SecurityHubConfigSerializer,
|
||||
replace_integration_providers,
|
||||
)
|
||||
from api.v1.serializer_utils.lighthouse import (
|
||||
BedrockCredentialsSerializer,
|
||||
@@ -1568,14 +1569,14 @@ class FindingMetadataSerializer(BaseSerializerV1):
|
||||
|
||||
|
||||
# Provider secrets
|
||||
KUBERNETES_KUBECONFIG_EXEC_ERROR = (
|
||||
"Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud "
|
||||
"for security reasons."
|
||||
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR = (
|
||||
"Kubernetes kubeconfig command-based authentication is not supported in "
|
||||
"Prowler Cloud for security reasons."
|
||||
)
|
||||
KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content."
|
||||
|
||||
|
||||
def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
|
||||
def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool:
|
||||
users = kubeconfig.get("users", [])
|
||||
if not isinstance(users, list):
|
||||
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
|
||||
@@ -1591,6 +1592,17 @@ def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
|
||||
if "exec" in user:
|
||||
return True
|
||||
|
||||
auth_provider = user.get("auth-provider", {})
|
||||
if not isinstance(auth_provider, dict):
|
||||
continue
|
||||
|
||||
auth_provider_config = auth_provider.get("config", {})
|
||||
if not isinstance(auth_provider_config, dict):
|
||||
continue
|
||||
|
||||
if "cmd-path" in auth_provider_config:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -1672,6 +1684,7 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
|
||||
validation_error.detail[f"secret/{key}"] = value
|
||||
del validation_error.detail[key]
|
||||
raise validation_error
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
class AwsProviderSecret(serializers.Serializer):
|
||||
@@ -1786,8 +1799,10 @@ class KubernetesProviderSecret(serializers.Serializer):
|
||||
if not isinstance(kubeconfig, dict):
|
||||
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
|
||||
|
||||
if kubeconfig_contains_exec_auth(kubeconfig):
|
||||
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_EXEC_ERROR)
|
||||
if kubeconfig_contains_unsupported_command_auth(kubeconfig):
|
||||
raise serializers.ValidationError(
|
||||
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR
|
||||
)
|
||||
|
||||
return kubeconfig_content
|
||||
|
||||
@@ -1813,14 +1828,32 @@ class IacProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class LegacyOCIRegionField(serializers.Field):
|
||||
def to_internal_value(self, data):
|
||||
return data
|
||||
|
||||
def to_representation(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class OracleCloudProviderSecret(serializers.Serializer):
|
||||
user = serializers.CharField()
|
||||
fingerprint = serializers.CharField()
|
||||
key_file = serializers.CharField(required=False)
|
||||
key_content = serializers.CharField(required=False)
|
||||
tenancy = serializers.CharField()
|
||||
region = serializers.CharField()
|
||||
pass_phrase = serializers.CharField(required=False)
|
||||
region = LegacyOCIRegionField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs.pop("region", None)
|
||||
|
||||
if "key_file" not in attrs and "key_content" not in attrs:
|
||||
raise serializers.ValidationError(
|
||||
{"key_file": "Either key_file or key_content must be provided."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
@@ -1965,7 +1998,11 @@ class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSeria
|
||||
secret = attrs.get("secret")
|
||||
|
||||
validated_attrs = super().validate(attrs)
|
||||
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
@@ -1997,7 +2034,11 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
|
||||
secret = attrs.get("secret")
|
||||
|
||||
validated_attrs = super().validate(attrs)
|
||||
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
@@ -2716,6 +2757,37 @@ class ScheduleDailyCreateSerializer(BaseSerializerV1):
|
||||
# Integrations
|
||||
|
||||
|
||||
class IntegrationProviderVisibilityMixin:
|
||||
"""
|
||||
Keep the `providers` relationship within the provider visibility of the role.
|
||||
|
||||
The view injects `allowed_providers` in the serializer context: `None` when the role
|
||||
has unlimited visibility, and the queryset of visible providers otherwise. Roles with
|
||||
limited visibility can neither attach providers they cannot see nor discover, through
|
||||
the serialized output, the ones already attached.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
if allowed_providers is not None:
|
||||
self.fields["providers"].child_relation.queryset = allowed_providers
|
||||
|
||||
def hide_restricted_providers(self, representation: dict) -> dict:
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
# `providers` is missing when the request asks for a subset of the fields
|
||||
if allowed_providers is None or "providers" not in representation:
|
||||
return representation
|
||||
|
||||
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
|
||||
representation["providers"] = [
|
||||
provider
|
||||
for provider in representation["providers"]
|
||||
if provider["id"] in allowed_provider_ids
|
||||
]
|
||||
return representation
|
||||
|
||||
|
||||
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
def validate(self, attrs):
|
||||
integration_type = attrs.get("integration_type")
|
||||
@@ -2848,7 +2920,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
)
|
||||
|
||||
|
||||
class IntegrationSerializer(RLSSerializer):
|
||||
class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
|
||||
"""
|
||||
Serializer for the Integration model.
|
||||
"""
|
||||
@@ -2877,15 +2949,9 @@ class IntegrationSerializer(RLSSerializer):
|
||||
}
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
if allowed_providers:
|
||||
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
|
||||
representation["providers"] = [
|
||||
provider
|
||||
for provider in representation["providers"]
|
||||
if provider["id"] in allowed_provider_ids
|
||||
]
|
||||
representation = self.hide_restricted_providers(
|
||||
super().to_representation(instance)
|
||||
)
|
||||
if instance.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
representation["configuration"].update(
|
||||
{"domain": instance.credentials.get("domain")}
|
||||
@@ -2893,7 +2959,9 @@ class IntegrationSerializer(RLSSerializer):
|
||||
return representation
|
||||
|
||||
|
||||
class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
|
||||
class IntegrationCreateSerializer(
|
||||
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
|
||||
):
|
||||
credentials = IntegrationCredentialField(write_only=True)
|
||||
configuration = IntegrationConfigField()
|
||||
providers = serializers.ResourceRelatedField(
|
||||
@@ -2944,22 +3012,18 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
|
||||
providers = validated_data.pop("providers", [])
|
||||
integration = Integration.objects.create(tenant_id=tenant_id, **validated_data)
|
||||
|
||||
through_model_instances = [
|
||||
IntegrationProviderRelationship(
|
||||
integration=integration,
|
||||
provider=provider,
|
||||
tenant_id=tenant_id,
|
||||
with transaction.atomic():
|
||||
integration = Integration.objects.create(
|
||||
tenant_id=tenant_id, **validated_data
|
||||
)
|
||||
for provider in providers
|
||||
]
|
||||
IntegrationProviderRelationship.objects.bulk_create(through_model_instances)
|
||||
replace_integration_providers(integration, providers, tenant_id)
|
||||
|
||||
return integration
|
||||
|
||||
|
||||
class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
class IntegrationUpdateSerializer(
|
||||
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
|
||||
):
|
||||
credentials = IntegrationCredentialField(write_only=True, required=False)
|
||||
configuration = IntegrationConfigField(required=False)
|
||||
providers = serializers.ResourceRelatedField(
|
||||
@@ -3004,15 +3068,13 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
if validated_data.get("providers") is not None:
|
||||
instance.providers.clear()
|
||||
new_relationships = [
|
||||
IntegrationProviderRelationship(
|
||||
integration=instance, provider=provider, tenant_id=tenant_id
|
||||
)
|
||||
for provider in validated_data["providers"]
|
||||
]
|
||||
IntegrationProviderRelationship.objects.bulk_create(new_relationships)
|
||||
# Relationships are replaced here, so they are kept out of the default
|
||||
# `ModelSerializer.update()`, which would otherwise reset them all. The view
|
||||
# rejects updates on integrations shared with providers hidden to the role, so
|
||||
# every existing relationship is visible to the requester at this point
|
||||
providers = validated_data.pop("providers", None)
|
||||
if providers is not None:
|
||||
replace_integration_providers(instance, providers, tenant_id)
|
||||
|
||||
# Preserve regions field for Security Hub integrations
|
||||
if instance.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB:
|
||||
@@ -3024,7 +3086,9 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
representation = self.hide_restricted_providers(
|
||||
super().to_representation(instance)
|
||||
)
|
||||
# Ensure JIRA integrations show updated domain in configuration from credentials
|
||||
if instance.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
representation["configuration"].update(
|
||||
|
||||
@@ -124,7 +124,12 @@ from api.models import (
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.pagination import ComplianceOverviewPagination
|
||||
from api.rbac.permissions import Permissions, get_providers, get_role
|
||||
from api.rbac.permissions import (
|
||||
Permissions,
|
||||
get_integrations,
|
||||
get_providers,
|
||||
get_role,
|
||||
)
|
||||
from api.renderers import APIJSONRenderer, PlainTextRenderer
|
||||
from api.rls import Tenant
|
||||
from api.utils import (
|
||||
@@ -281,6 +286,7 @@ from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from drf_spectacular.settings import spectacular_settings
|
||||
@@ -6652,27 +6658,34 @@ class ScheduleViewSet(BaseRLSViewSet):
|
||||
list=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="List all integrations",
|
||||
description="Retrieve a list of all configured integrations with options for filtering by various criteria.",
|
||||
description="Retrieve a list of all configured integrations with options for filtering by various criteria.\n\n"
|
||||
"Integrations attached to one or more providers are only returned when the role can access at least one of "
|
||||
"those providers, and each integration lists only the providers visible to the role. Integrations not "
|
||||
"attached to any provider, such as Jira, are tenant-wide and are returned for every role.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Retrieve integration details",
|
||||
description="Fetch detailed information about a specific integration by its ID.",
|
||||
description="Fetch detailed information about a specific integration by its ID. Integrations outside the "
|
||||
"provider visibility of the role are reported the same way as one that does not exist.",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Create a new integration",
|
||||
description="Register a new integration with the system, providing necessary configuration details.",
|
||||
description="Register a new integration with the system, providing necessary configuration details. Only "
|
||||
"providers visible to the role can be attached to the integration.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Partially update an integration",
|
||||
description="Modify certain fields of an existing integration without affecting other settings.",
|
||||
description="Modify certain fields of an existing integration without affecting other settings. Integrations "
|
||||
"attached to providers outside the visibility of the role cannot be modified by it.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Delete an integration",
|
||||
description="Remove an integration from the system by its ID.",
|
||||
description="Remove an integration from the system by its ID. Integrations attached to providers outside "
|
||||
"the visibility of the role cannot be deleted by it.",
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@@ -6685,18 +6698,27 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
ordering = ["integration_type", "-inserted_at"]
|
||||
# RBAC required permissions
|
||||
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
allowed_providers = None
|
||||
|
||||
@cached_property
|
||||
def allowed_providers(self):
|
||||
"""
|
||||
Providers the role can access, or None when it has unlimited visibility.
|
||||
|
||||
Resolved per request and independently of the action, so that writes are scoped
|
||||
as tightly as reads.
|
||||
"""
|
||||
if self.user_role.unlimited_visibility:
|
||||
return None
|
||||
return get_providers(self.user_role)
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all integrations
|
||||
queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter providers based on provider groups associated with the role
|
||||
allowed_providers = get_providers(user_roles)
|
||||
queryset = Integration.objects.filter(providers__in=allowed_providers)
|
||||
self.allowed_providers = allowed_providers
|
||||
queryset = get_integrations(self.user_role, providers=self.allowed_providers)
|
||||
if self.allowed_providers is not None and self.action in ("list", "retrieve"):
|
||||
# Restrict the relationship itself, so that the providers hidden to the role
|
||||
# are left out of the sideloaded resources of `?include=providers` too
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch("providers", queryset=self.allowed_providers)
|
||||
)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -6711,16 +6733,33 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
context["allowed_providers"] = self.allowed_providers
|
||||
return context
|
||||
|
||||
def get_object(self):
|
||||
instance = super().get_object()
|
||||
# Writes on an integration shared with providers hidden to the role would reach
|
||||
# beyond its visibility, so both editing and deleting are rejected consistently
|
||||
if (
|
||||
self.action in ("partial_update", "destroy")
|
||||
and self.allowed_providers is not None
|
||||
and instance.providers.exclude(
|
||||
id__in=self.allowed_providers.values("id")
|
||||
).exists()
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"The integration is attached to providers outside the visibility of your role."
|
||||
)
|
||||
return instance
|
||||
|
||||
@extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Check integration connection",
|
||||
description="Try to verify integration connection",
|
||||
description="Try to verify integration connection. Integrations outside the provider visibility of the role "
|
||||
"are reported the same way as one that does not exist.",
|
||||
request=None,
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_name="connection")
|
||||
def connection(self, request, pk=None):
|
||||
get_object_or_404(Integration, pk=pk)
|
||||
get_object_or_404(self.get_queryset(), pk=pk)
|
||||
with transaction.atomic():
|
||||
task = check_integration_connection_task.delay(
|
||||
integration_id=pk, tenant_id=self.request.tenant_id
|
||||
@@ -6743,7 +6782,8 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
tags=["Integration"],
|
||||
summary="Send findings to a Jira integration",
|
||||
description="Send a set of filtered findings to the given integration. At least one finding filter must be "
|
||||
"provided.\n\n"
|
||||
"provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings "
|
||||
"sent are limited to the providers the role can access.\n\n"
|
||||
"## Known Limitations\n\n"
|
||||
"### Issue Types with Required Custom Fields\n\n"
|
||||
"Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not "
|
||||
@@ -6787,24 +6827,37 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
return []
|
||||
return super().get_filter_backends()
|
||||
|
||||
def get_queryset(self):
|
||||
tenant_id = self.request.tenant_id
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all findings
|
||||
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter findings based on provider groups associated with the role
|
||||
queryset = Finding.all_objects.filter(
|
||||
scan__provider__in=get_providers(user_roles)
|
||||
)
|
||||
@cached_property
|
||||
def allowed_providers(self):
|
||||
"""
|
||||
Providers the role can access, or None when it has unlimited visibility.
|
||||
|
||||
return queryset
|
||||
Resolved once per request and shared between the findings queryset and the
|
||||
integration lookup.
|
||||
"""
|
||||
if self.user_role.unlimited_visibility:
|
||||
return None
|
||||
return get_providers(self.user_role)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.allowed_providers is None:
|
||||
# User has unlimited visibility, return all findings
|
||||
return Finding.all_objects.filter(tenant_id=self.request.tenant_id)
|
||||
# Findings are limited to the providers the role can access
|
||||
return Finding.all_objects.filter(scan__provider__in=self.allowed_providers)
|
||||
|
||||
def get_integration(self, integration_pk):
|
||||
"""Retrieve the integration, honoring the provider visibility of the user's role."""
|
||||
return get_object_or_404(
|
||||
get_integrations(self.user_role, providers=self.allowed_providers),
|
||||
pk=integration_pk,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Get available issue types for a Jira project",
|
||||
description="Fetch the available issue types from Jira for a given project key and update the integration configuration.",
|
||||
description="Fetch the available issue types from Jira for a given project key and update the integration "
|
||||
"configuration. Jira integrations are tenant-wide and do not require unlimited visibility.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="project_key",
|
||||
@@ -6817,7 +6870,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
)
|
||||
@action(detail=False, methods=["get"], url_name="issue-types")
|
||||
def issue_types(self, request, integration_pk=None):
|
||||
integration = get_object_or_404(Integration, pk=integration_pk)
|
||||
integration = self.get_integration(integration_pk)
|
||||
|
||||
project_key = request.query_params.get("project_key")
|
||||
if not project_key:
|
||||
@@ -6862,23 +6915,23 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
|
||||
@action(detail=False, methods=["post"], url_name="dispatches")
|
||||
def dispatches(self, request, integration_pk=None):
|
||||
get_object_or_404(Integration, pk=integration_pk)
|
||||
self.get_integration(integration_pk)
|
||||
serializer = self.get_serializer(
|
||||
data=request.data, context={"integration_id": integration_pk}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if self.filter_queryset(self.get_queryset()).count() == 0:
|
||||
raise ValidationError(
|
||||
{"findings": "No findings match the provided filters"}
|
||||
)
|
||||
|
||||
finding_ids = [
|
||||
str(finding_id)
|
||||
for finding_id in self.filter_queryset(self.get_queryset()).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
]
|
||||
if not finding_ids:
|
||||
raise ValidationError(
|
||||
{"findings": "No findings match the provided filters"}
|
||||
)
|
||||
|
||||
project_key = serializer.validated_data["project_key"]
|
||||
issue_type = serializer.validated_data["issue_type"]
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ celery_app.conf.task_annotations = {
|
||||
for name in (
|
||||
"scan-perform",
|
||||
"scan-perform-scheduled",
|
||||
"attack-paths-scan-perform",
|
||||
"provider-deletion",
|
||||
"tenant-deletion",
|
||||
)
|
||||
|
||||
@@ -312,8 +312,8 @@ ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES", 30
|
||||
)
|
||||
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
|
||||
) # 48h
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960
|
||||
) # 16h
|
||||
|
||||
# Selects where the persistent attack-paths graph is stored. The scan
|
||||
# temporary database is always Neo4j; only the sink is configurable.
|
||||
|
||||
@@ -91,6 +91,13 @@ def before_send(event, hint):
|
||||
log_msg = log_record.getMessage()
|
||||
log_lvl = log_record.levelno
|
||||
|
||||
if (
|
||||
getattr(log_record, "name", "") == "cartography.graph.job"
|
||||
and "Neo.ClientError.Database.DatabaseNotFound" in log_msg
|
||||
and "db-tmp-scan-" in log_msg
|
||||
):
|
||||
return None
|
||||
|
||||
# The Neo4j driver logs transient connection errors (defunct
|
||||
# connections, resets) at ERROR level via the `neo4j.io` logger.
|
||||
# `RetryableSession` handles these with retries. If all retries
|
||||
|
||||
@@ -13,16 +13,17 @@ GITHUB_OAUTH_CALLBACK_URL = env("SOCIAL_GITHUB_OAUTH_CALLBACK_URL", default="")
|
||||
ACCOUNT_LOGIN_METHODS = {"email"} # Use Email / Password authentication
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
|
||||
ACCOUNT_EMAIL_VERIFICATION = "none" # Do not require email confirmation
|
||||
ACCOUNT_EMAIL_NOTIFICATIONS = False
|
||||
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||
REST_AUTH = {
|
||||
"TOKEN_MODEL": None,
|
||||
"REST_USE_JWT": True,
|
||||
}
|
||||
# django-allauth (social)
|
||||
# Authenticate if local account with this email address already exists
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
|
||||
# Connect local account and social account if local account with that email address already exists
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
||||
# Email-based account matching is handled by ProwlerSocialAccountAdapter, which
|
||||
# verifies both the provider email and the existing account email before linking.
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = False
|
||||
SOCIALACCOUNT_ADAPTER = "api.adapters.ProwlerSocialAccountAdapter"
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import aioboto3
|
||||
import boto3
|
||||
import botocore
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.database import DATABASE_NOT_FOUND_CODE
|
||||
from api.models import (
|
||||
AttackPathsScan as ProwlerAPIAttackPathsScan,
|
||||
)
|
||||
@@ -347,6 +349,12 @@ def sync_aws_account(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if (
|
||||
isinstance(e, neo4j.exceptions.Neo4jError)
|
||||
and e.code == DATABASE_NOT_FOUND_CODE
|
||||
):
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Synced function {func_name} for AWS account {prowler_api_provider.uid} in {time.perf_counter() - func_t0:.3f}s (FAILED)"
|
||||
)
|
||||
|
||||
@@ -10,13 +10,10 @@ NormalizedList = _provider_config.NormalizedList
|
||||
PROVIDER_CONFIGS = _provider_config.PROVIDER_CONFIGS
|
||||
ProviderConfig = _provider_config.ProviderConfig
|
||||
|
||||
# Batch size for Neo4j write operations (resource labeling, cleanup)
|
||||
BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
|
||||
# Batch size for graph mutation operations (resource labeling and subgraph deletion)
|
||||
GRAPH_MUTATION_BATCH_SIZE = env.int("ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE", 1000)
|
||||
# Batch size for Postgres findings fetch (keyset pagination page size)
|
||||
FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000)
|
||||
# Batch size for temp-to-tenant graph sync (nodes and relationships per cursor page)
|
||||
SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 1000)
|
||||
|
||||
# Neo4j internal labels (Prowler-specific, not provider-specific)
|
||||
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
|
||||
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources
|
||||
|
||||
@@ -21,8 +21,8 @@ from cartography.config import Config as CartographyConfig
|
||||
from celery.utils.log import get_task_logger
|
||||
from prowler.config import config as ProwlerConfig
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
FINDINGS_BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
get_node_uid_field,
|
||||
get_provider_resource_label,
|
||||
get_root_node_label,
|
||||
@@ -135,7 +135,7 @@ def add_resource_label(
|
||||
while labeled_count > 0:
|
||||
result = neo4j_session.run(
|
||||
query,
|
||||
{"provider_uid": provider_uid, "batch_size": BATCH_SIZE},
|
||||
{"provider_uid": provider_uid, "batch_size": GRAPH_MUTATION_BATCH_SIZE},
|
||||
)
|
||||
labeled_count = result.single().get("labeled_count", 0)
|
||||
total_labeled += labeled_count
|
||||
|
||||
@@ -372,7 +372,19 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
|
||||
except Exception as e:
|
||||
exception_message = utils.stringify_exception(e, "Attack Paths scan failed")
|
||||
logger.exception(exception_message)
|
||||
temporary_database_missing = (
|
||||
isinstance(e, graph_database.GraphDatabaseQueryException)
|
||||
and e.code == graph_database.DATABASE_NOT_FOUND_CODE
|
||||
and tmp_database_name in str(e)
|
||||
)
|
||||
if temporary_database_missing:
|
||||
logger.warning(exception_message)
|
||||
else:
|
||||
logger.exception(exception_message)
|
||||
cleanup_log_level = (
|
||||
logging.WARNING if temporary_database_missing else logging.ERROR
|
||||
)
|
||||
cleanup_exc_info = not temporary_database_missing
|
||||
ingestion_exceptions["global_error"] = exception_message
|
||||
|
||||
# Recover `graph_data_ready` based on how far the swap got
|
||||
@@ -387,19 +399,24 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}",
|
||||
exc_info=True,
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
"Failed to recover `graph_data_ready` for provider "
|
||||
f"{attack_paths_scan.provider_id}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
# Dropping the temporary database if it still exists
|
||||
try:
|
||||
graph_database.drop_database(tmp_cartography_config.neo4j_database)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to drop temporary Neo4j database `{tmp_cartography_config.neo4j_database}` during cleanup: {e}",
|
||||
exc_info=True,
|
||||
except Exception as cleanup_error:
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
"Failed to drop temporary Neo4j database "
|
||||
f"`{tmp_cartography_config.neo4j_database}` during cleanup: "
|
||||
f"{cleanup_error}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
# Set Attack Paths scan state to FAILED
|
||||
@@ -407,10 +424,12 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
db_utils.finish_attack_paths_scan(
|
||||
attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` (row may have been deleted): {e}",
|
||||
exc_info=True,
|
||||
except Exception as cleanup_error:
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
|
||||
f"(row may have been deleted): {cleanup_error}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
@@ -30,7 +30,6 @@ from tasks.jobs.attack_paths.config import (
|
||||
PROVIDER_CONFIGS,
|
||||
PROVIDER_ISOLATION_PROPERTIES,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
SYNC_BATCH_SIZE,
|
||||
NormalizedList,
|
||||
get_provider_label,
|
||||
get_tenant_label,
|
||||
@@ -116,6 +115,7 @@ def sync_nodes(
|
||||
Source and target sessions are opened sequentially per batch to avoid
|
||||
holding two Bolt connections simultaneously for the entire sync duration.
|
||||
"""
|
||||
batch_size = sink.sync_batch_size
|
||||
t0 = time.perf_counter()
|
||||
last_id = -1
|
||||
parents_synced = 0
|
||||
@@ -137,7 +137,7 @@ def sync_nodes(
|
||||
with graph_database.get_session(source_database) as source_session:
|
||||
result = source_session.run(
|
||||
NODE_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
|
||||
{"last_id": last_id, "batch_size": batch_size},
|
||||
)
|
||||
for record in result:
|
||||
batch_count += 1
|
||||
@@ -156,17 +156,17 @@ def sync_nodes(
|
||||
|
||||
for labels, batch in parent_groups.items():
|
||||
rendered_labels = _render_labels(labels, extra_labels)
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_nodes(target_database, rendered_labels, sink_batch)
|
||||
|
||||
for child_label, batch in child_groups.items():
|
||||
rendered_labels = _render_labels((child_label,), extra_labels)
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_nodes(target_database, rendered_labels, sink_batch)
|
||||
children_synced += len(batch)
|
||||
|
||||
for rel_type, batch in rel_groups.items():
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_relationships(
|
||||
target_database, rel_type, provider_id, sink_batch
|
||||
)
|
||||
@@ -205,6 +205,7 @@ def sync_relationships(
|
||||
Source and target sessions are opened sequentially per batch to avoid
|
||||
holding two Bolt connections simultaneously for the entire sync duration.
|
||||
"""
|
||||
batch_size = sink.sync_batch_size
|
||||
t0 = time.perf_counter()
|
||||
last_id = -1
|
||||
total_synced = 0
|
||||
@@ -217,7 +218,7 @@ def sync_relationships(
|
||||
with graph_database.get_session(source_database) as source_session:
|
||||
result = source_session.run(
|
||||
RELATIONSHIPS_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
|
||||
{"last_id": last_id, "batch_size": batch_size},
|
||||
)
|
||||
for record in result:
|
||||
batch_count += 1
|
||||
@@ -229,7 +230,7 @@ def sync_relationships(
|
||||
break
|
||||
|
||||
for rel_type, batch in grouped.items():
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_relationships(
|
||||
target_database, rel_type, provider_id, sink_batch
|
||||
)
|
||||
@@ -247,10 +248,9 @@ def sync_relationships(
|
||||
|
||||
def _iter_sink_batches(
|
||||
rows: list[dict[str, Any]],
|
||||
batch_size: int | None = None,
|
||||
batch_size: int,
|
||||
) -> Iterator[list[dict[str, Any]]]:
|
||||
"""Yield final sink write batches after source rows have been transformed."""
|
||||
batch_size = SYNC_BATCH_SIZE if batch_size is None else batch_size
|
||||
if batch_size <= 0:
|
||||
raise ValueError("Sink batch size must be greater than zero")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from glob import glob
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
@@ -214,8 +215,10 @@ def get_security_hub_client_from_integration(
|
||||
for region in set(all_security_hub_regions):
|
||||
regions_status[region] = region in connection.enabled_regions
|
||||
|
||||
# Save regions information in the integration configuration
|
||||
# Persist the successful connection check and regions information
|
||||
with rls_transaction(tenant_id, using=MainRouter.default_db):
|
||||
integration.connected = True
|
||||
integration.connection_last_checked_at = datetime.now(tz=UTC)
|
||||
integration.configuration["regions"] = regions_status
|
||||
integration.save()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -6,7 +7,7 @@ import re
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -49,6 +50,7 @@ from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from config.env import env
|
||||
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import DatabaseError, IntegrityError, OperationalError, transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
@@ -99,6 +101,16 @@ COMPLIANCE_REQUIREMENT_COPY_COLUMNS = (
|
||||
FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=3000)
|
||||
# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres.
|
||||
SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=1000)
|
||||
# Rows per COPY statement when ingesting compliance requirement overviews. All
|
||||
# batches of a scan share one transaction/commit; the batch size only bounds the
|
||||
# client-side CSV buffer and how long each individual COPY statement runs on the
|
||||
# writer (memory footprint, lock time and slow-statement logging under load).
|
||||
COMPLIANCE_COPY_BATCH_SIZE = env.int("DJANGO_COMPLIANCE_COPY_BATCH_SIZE", default=2000)
|
||||
if COMPLIANCE_COPY_BATCH_SIZE < 1:
|
||||
raise ImproperlyConfigured(
|
||||
"DJANGO_COMPLIANCE_COPY_BATCH_SIZE must be a positive integer, got "
|
||||
f"{COMPLIANCE_COPY_BATCH_SIZE}"
|
||||
)
|
||||
# Throttle scan progress persistence: minimum progress delta (fraction 0-1)
|
||||
# between two persisted progress updates.
|
||||
PROGRESS_THROTTLE_DELTA = env.float("DJANGO_SCAN_PROGRESS_THROTTLE_DELTA", default=0.01)
|
||||
@@ -356,30 +368,36 @@ def _bulk_update_resource_failed_findings_counts(
|
||||
raise
|
||||
|
||||
|
||||
def _copy_compliance_requirement_rows(
|
||||
tenant_id: str, rows: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Stream compliance requirement rows into Postgres using COPY.
|
||||
class ComplianceRowScopeError(ValueError):
|
||||
"""A compliance requirement row does not belong to the scan being ingested."""
|
||||
|
||||
We leverage the admin connection (when available) to bypass the COPY + RLS
|
||||
restriction, writing only the fields required by
|
||||
``ComplianceRequirementOverview``.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
rows: List of row dictionaries prepared by
|
||||
:func:`create_compliance_requirements`.
|
||||
def _compliance_requirement_rows_to_csv(
|
||||
rows: list[dict[str, Any]], tenant_id: str, scan_id: str
|
||||
) -> io.StringIO:
|
||||
"""Serialize compliance requirement rows into a CSV buffer for COPY.
|
||||
|
||||
COPY runs on the admin connection, which bypasses RLS, so every row is
|
||||
checked against the expected tenant/scan before it is written: a mismatched
|
||||
row would otherwise be inserted verbatim into another tenant's data.
|
||||
"""
|
||||
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer)
|
||||
|
||||
datetime_now = datetime.now(tz=UTC)
|
||||
for row in rows:
|
||||
row_tenant_id = str(row.get("tenant_id"))
|
||||
row_scan_id = str(row.get("scan_id"))
|
||||
if row_tenant_id != tenant_id or row_scan_id != scan_id:
|
||||
raise ComplianceRowScopeError(
|
||||
"Compliance requirement row does not belong to the scan being "
|
||||
f"ingested (expected tenant {tenant_id} / scan {scan_id}, got "
|
||||
f"tenant {row_tenant_id} / scan {row_scan_id})"
|
||||
)
|
||||
writer.writerow(
|
||||
[
|
||||
str(row.get("id")),
|
||||
str(row.get("tenant_id")),
|
||||
row_tenant_id,
|
||||
(row.get("inserted_at") or datetime_now).isoformat(),
|
||||
row.get("compliance_id") or "",
|
||||
row.get("framework") or "",
|
||||
@@ -393,65 +411,100 @@ def _copy_compliance_requirement_rows(
|
||||
row.get("total_checks", 0),
|
||||
row.get("passed_findings", 0),
|
||||
row.get("total_findings", 0),
|
||||
str(row.get("scan_id")),
|
||||
row_scan_id,
|
||||
]
|
||||
)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
return csv_buffer
|
||||
|
||||
|
||||
def _copy_compliance_requirement_rows(
|
||||
tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
|
||||
) -> int:
|
||||
"""Replace a scan's compliance requirement rows using batched COPY.
|
||||
|
||||
We leverage the admin connection (when available) to bypass the COPY + RLS
|
||||
restriction. The scan's DELETE and every COPY batch run on one connection
|
||||
inside a single transaction with a single commit, so the writer takes one
|
||||
fsync per scan instead of one per batch, and a failed ingest rolls back
|
||||
without committing a partial delete/insert (which a retry would otherwise
|
||||
delete again, feeding dead rows to autovacuum).
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
scan_id: Scan whose previous rows are replaced.
|
||||
rows: Iterable of row dictionaries, consumed lazily batch by batch.
|
||||
batch_size: Number of rows per COPY statement.
|
||||
|
||||
Returns:
|
||||
int: total number of rows staged and committed.
|
||||
|
||||
Raises:
|
||||
ComplianceRowScopeError: A row belongs to another tenant or scan.
|
||||
"""
|
||||
# Normalized once so the per-row scope check compares like with like even if
|
||||
# the caller passes UUID instances instead of strings.
|
||||
tenant_id = str(tenant_id)
|
||||
scan_id = str(scan_id)
|
||||
total_rows = 0
|
||||
batch_num = 0
|
||||
copy_sql = (
|
||||
"COPY compliance_requirements_overviews ("
|
||||
+ ", ".join(COMPLIANCE_REQUIREMENT_COPY_COLUMNS)
|
||||
+ ") FROM STDIN WITH (FORMAT CSV, DELIMITER ',', QUOTE '\"', ESCAPE '\"', NULL '\\N')"
|
||||
)
|
||||
|
||||
try:
|
||||
with psycopg_connection(MainRouter.admin_db) as connection:
|
||||
connection.autocommit = False
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
|
||||
cursor.copy_expert(copy_sql, csv_buffer)
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
csv_buffer.close()
|
||||
with psycopg_connection(MainRouter.admin_db) as connection:
|
||||
connection.autocommit = False
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
|
||||
# Idempotent re-run: clearing this scan's rows inside the same
|
||||
# transaction keeps delete + reinsert atomic.
|
||||
cursor.execute(
|
||||
"DELETE FROM compliance_requirements_overviews "
|
||||
"WHERE tenant_id = %s AND scan_id = %s",
|
||||
[tenant_id, scan_id],
|
||||
)
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
batch_num += 1
|
||||
csv_buffer = _compliance_requirement_rows_to_csv(
|
||||
batch, tenant_id, scan_id
|
||||
)
|
||||
try:
|
||||
cursor.copy_expert(copy_sql, csv_buffer)
|
||||
finally:
|
||||
csv_buffer.close()
|
||||
total_rows += len(batch)
|
||||
logger.info(
|
||||
f"Compliance COPY batch {batch_num}: staged {len(batch)} rows "
|
||||
f"({total_rows} total)"
|
||||
)
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
return total_rows
|
||||
|
||||
|
||||
def _persist_compliance_requirement_rows(
|
||||
tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000
|
||||
def _bulk_create_compliance_requirement_rows(
|
||||
tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
|
||||
) -> int:
|
||||
"""Persist compliance requirement rows using batched COPY with ORM fallback.
|
||||
"""Replace a scan's compliance requirement rows via the ORM.
|
||||
|
||||
``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size``
|
||||
rows instead of the full set. A batch that fails COPY falls back to an ORM
|
||||
``bulk_create`` of just that batch.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
rows: Iterable of row dictionaries reflecting the compliance overview
|
||||
state for a scan.
|
||||
batch_size: Number of rows per COPY batch (default: 10000).
|
||||
|
||||
Returns:
|
||||
int: total number of rows persisted.
|
||||
Fallback for when COPY is unavailable; the delete and every ``bulk_create``
|
||||
share one RLS transaction so the replacement stays atomic.
|
||||
"""
|
||||
total_rows = 0
|
||||
batch_num = 0
|
||||
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
batch_num += 1
|
||||
try:
|
||||
_copy_compliance_requirement_rows(tenant_id, batch)
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
f"COPY bulk insert for compliance requirements batch {batch_num} "
|
||||
"failed; falling back to ORM bulk_create for this batch",
|
||||
exc_info=error,
|
||||
)
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
fallback_objects = [
|
||||
ComplianceRequirementOverview(
|
||||
id=row["id"],
|
||||
@@ -473,20 +526,58 @@ def _persist_compliance_requirement_rows(
|
||||
)
|
||||
for row in batch
|
||||
]
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.bulk_create(
|
||||
fallback_objects, batch_size=500
|
||||
)
|
||||
|
||||
total_rows += len(batch)
|
||||
logger.info(
|
||||
f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows "
|
||||
f"({total_rows} total)"
|
||||
)
|
||||
|
||||
ComplianceRequirementOverview.objects.bulk_create(
|
||||
fallback_objects, batch_size=500
|
||||
)
|
||||
total_rows += len(batch)
|
||||
return total_rows
|
||||
|
||||
|
||||
def _persist_compliance_requirement_rows(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
rows_factory: Callable[[], Iterable[dict[str, Any]]],
|
||||
batch_size: int | None = None,
|
||||
) -> int:
|
||||
"""Persist a scan's compliance requirement rows, replacing any previous ones.
|
||||
|
||||
``rows_factory`` must return a fresh row iterator on every call: the COPY
|
||||
path consumes it lazily in batches (peak memory ~``batch_size`` rows), and
|
||||
if COPY fails the whole ingest falls back to a single ORM transaction that
|
||||
re-iterates the rows.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
scan_id: Scan whose compliance overview rows are being replaced.
|
||||
rows_factory: Callable returning an iterable of row dictionaries.
|
||||
batch_size: Rows per COPY/bulk_create batch (default:
|
||||
``COMPLIANCE_COPY_BATCH_SIZE``).
|
||||
|
||||
Returns:
|
||||
int: total number of rows persisted.
|
||||
"""
|
||||
if batch_size is None:
|
||||
batch_size = COMPLIANCE_COPY_BATCH_SIZE
|
||||
|
||||
try:
|
||||
return _copy_compliance_requirement_rows(
|
||||
tenant_id, scan_id, rows_factory(), batch_size
|
||||
)
|
||||
except ComplianceRowScopeError:
|
||||
# Cross-tenant/scan rows are a bug in the caller, not a COPY failure:
|
||||
# retrying through the ORM would persist the very rows we rejected.
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"COPY bulk insert for compliance requirements failed; "
|
||||
"falling back to ORM bulk_create",
|
||||
exc_info=error,
|
||||
)
|
||||
return _bulk_create_compliance_requirement_rows(
|
||||
tenant_id, scan_id, rows_factory(), batch_size
|
||||
)
|
||||
|
||||
|
||||
def _create_compliance_summaries(
|
||||
tenant_id: str, scan_id: str, requirement_statuses: dict
|
||||
) -> None:
|
||||
@@ -605,6 +696,45 @@ def _process_finding_micro_batch(
|
||||
scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}.
|
||||
group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}.
|
||||
"""
|
||||
|
||||
def build_resource_defaults_from_finding(finding: ProwlerFinding) -> dict[str, Any]:
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"provider": provider_instance,
|
||||
"uid": finding.resource_uid,
|
||||
"region": finding.region,
|
||||
"service": finding.service_name,
|
||||
"type": finding.resource_type,
|
||||
"name": finding.resource_name,
|
||||
"groups": [group] if group else None,
|
||||
}
|
||||
|
||||
def recover_resource_after_cache_miss(finding: ProwlerFinding) -> Resource:
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
resource_instance = Resource.objects.create(
|
||||
**build_resource_defaults_from_finding(finding)
|
||||
)
|
||||
except IntegrityError:
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
raise
|
||||
|
||||
return cache_resource(resource_uid, resource_instance)
|
||||
|
||||
# Accumulate objects for bulk operations
|
||||
findings_to_create = []
|
||||
dirty_resources = {}
|
||||
@@ -643,7 +773,103 @@ def _process_finding_micro_batch(
|
||||
|
||||
# All DB writes for this micro-batch run inside ONE rls_transaction,
|
||||
# with deadlock-retry at micro-batch granularity instead of per-finding.
|
||||
missing_cache_value = object()
|
||||
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
|
||||
resource_cache_originals: dict[str, Resource | object] = {}
|
||||
failed_count_originals: dict[str, int | None] = {}
|
||||
resource_field_originals: dict[str, dict[str, Any]] = {}
|
||||
tag_cache_original = dict(tag_cache)
|
||||
scan_resource_cache_original = set(scan_resource_cache)
|
||||
scan_categories_cache_original = {
|
||||
key: value.copy() for key, value in scan_categories_cache.items()
|
||||
}
|
||||
scan_resource_groups_cache_original = {
|
||||
key: value.copy() for key, value in scan_resource_groups_cache.items()
|
||||
}
|
||||
group_resources_cache_original = {
|
||||
key: set(value) for key, value in group_resources_cache.items()
|
||||
}
|
||||
|
||||
def cache_resource(resource_uid: str, resource_instance: Resource) -> Resource:
|
||||
if resource_uid not in resource_cache_originals:
|
||||
resource_cache_originals[resource_uid] = resource_cache.get(
|
||||
resource_uid, missing_cache_value
|
||||
)
|
||||
resource_cache[resource_uid] = resource_instance
|
||||
if resource_uid not in resource_failed_findings_cache:
|
||||
failed_count_originals[resource_uid] = None
|
||||
resource_failed_findings_cache[resource_uid] = 0
|
||||
return resource_instance
|
||||
|
||||
def snapshot_failed_count(resource_uid: str) -> None:
|
||||
if resource_uid not in failed_count_originals:
|
||||
failed_count_originals[resource_uid] = (
|
||||
resource_failed_findings_cache.get(resource_uid)
|
||||
)
|
||||
|
||||
def snapshot_resource_fields(
|
||||
resource_uid: str, resource_instance: Resource
|
||||
) -> None:
|
||||
if resource_uid in resource_field_originals:
|
||||
return
|
||||
resource_field_originals[resource_uid] = {
|
||||
field: copy.deepcopy(getattr(resource_instance, field))
|
||||
for field in (
|
||||
"name",
|
||||
"metadata",
|
||||
"details",
|
||||
"partition",
|
||||
"region",
|
||||
"service",
|
||||
"type",
|
||||
"groups",
|
||||
"updated_at",
|
||||
)
|
||||
}
|
||||
|
||||
def restore_attempt_caches() -> None:
|
||||
for resource_uid, original_fields in resource_field_originals.items():
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
continue
|
||||
for field, value in original_fields.items():
|
||||
setattr(resource_instance, field, value)
|
||||
for resource_uid, original_resource in resource_cache_originals.items():
|
||||
if original_resource is missing_cache_value:
|
||||
resource_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_cache[resource_uid] = original_resource
|
||||
for resource_uid, original_count in failed_count_originals.items():
|
||||
if original_count is None:
|
||||
resource_failed_findings_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_failed_findings_cache[resource_uid] = original_count
|
||||
tag_cache.clear()
|
||||
tag_cache.update(tag_cache_original)
|
||||
scan_resource_cache.clear()
|
||||
scan_resource_cache.update(scan_resource_cache_original)
|
||||
scan_categories_cache.clear()
|
||||
scan_categories_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_categories_cache_original.items()
|
||||
}
|
||||
)
|
||||
scan_resource_groups_cache.clear()
|
||||
scan_resource_groups_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_resource_groups_cache_original.items()
|
||||
}
|
||||
)
|
||||
group_resources_cache.clear()
|
||||
group_resources_cache.update(
|
||||
{
|
||||
key: set(value)
|
||||
for key, value in group_resources_cache_original.items()
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with rls_transaction(tenant_id):
|
||||
# 1) Pre-resolve Resources in bulk
|
||||
@@ -678,19 +904,8 @@ def _process_finding_micro_batch(
|
||||
resources_to_create = []
|
||||
for uid in missing_uids:
|
||||
f = first_finding_per_uid[uid]
|
||||
check_metadata = f.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
resources_to_create.append(
|
||||
Resource(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_instance,
|
||||
uid=uid,
|
||||
region=f.region,
|
||||
service=f.service_name,
|
||||
type=f.resource_type,
|
||||
name=f.resource_name,
|
||||
groups=[group] if group else None,
|
||||
)
|
||||
Resource(**build_resource_defaults_from_finding(f))
|
||||
)
|
||||
Resource.objects.bulk_create(
|
||||
resources_to_create,
|
||||
@@ -711,8 +926,7 @@ def _process_finding_micro_batch(
|
||||
}
|
||||
)
|
||||
for uid, r in existing_resources.items():
|
||||
resource_cache[uid] = r
|
||||
resource_failed_findings_cache.setdefault(uid, 0)
|
||||
cache_resource(uid, r)
|
||||
|
||||
# 2) Pre-resolve ResourceTags in bulk
|
||||
batch_tag_kv: set[tuple[str, str]] = set()
|
||||
@@ -758,47 +972,50 @@ def _process_finding_micro_batch(
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
# Should be unreachable after the pre-resolve step. Defensive log.
|
||||
logger.error(
|
||||
f"Resource {resource_uid} missing from cache after pre-resolve "
|
||||
f"on scan {scan_instance.id}; skipping finding."
|
||||
)
|
||||
continue
|
||||
resource_instance = recover_resource_after_cache_miss(finding)
|
||||
|
||||
# Detect resource field changes (defer save until end-of-batch bulk_update).
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
updated = False
|
||||
if finding.region and resource_instance.region != finding.region:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.region = finding.region
|
||||
updated = True
|
||||
if (
|
||||
finding.resource_name
|
||||
and resource_instance.name != finding.resource_name
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.name = finding.resource_name
|
||||
updated = True
|
||||
if resource_instance.service != finding.service_name:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.service = finding.service_name
|
||||
updated = True
|
||||
if resource_instance.type != finding.resource_type:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.type = finding.resource_type
|
||||
updated = True
|
||||
if resource_instance.metadata != finding.resource_metadata:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.metadata = json.dumps(
|
||||
finding.resource_metadata, cls=CustomEncoder
|
||||
)
|
||||
updated = True
|
||||
if resource_instance.details != finding.resource_details:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.details = finding.resource_details
|
||||
updated = True
|
||||
if resource_instance.partition != finding.partition:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.partition = finding.partition
|
||||
updated = True
|
||||
if group and (
|
||||
not resource_instance.groups
|
||||
or group not in resource_instance.groups
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.groups = (resource_instance.groups or []) + [
|
||||
group
|
||||
]
|
||||
@@ -860,6 +1077,7 @@ def _process_finding_micro_batch(
|
||||
muted_reason = mute_rules_cache[finding_uid]
|
||||
|
||||
if status == FindingStatus.FAIL and not is_muted:
|
||||
snapshot_failed_count(resource_uid)
|
||||
resource_failed_findings_cache[resource_uid] += 1
|
||||
|
||||
check_metadata["compliance"] = finding.compliance
|
||||
@@ -885,15 +1103,19 @@ def _process_finding_micro_batch(
|
||||
# Denormalized resource arrays populated directly on insert
|
||||
# (was previously a separate bulk_update; saves a CASE WHEN
|
||||
# over thousands of rows per micro-batch).
|
||||
resource_regions=[resource_instance.region]
|
||||
if resource_instance.region
|
||||
else [],
|
||||
resource_services=[resource_instance.service]
|
||||
if resource_instance.service
|
||||
else [],
|
||||
resource_types=[resource_instance.type]
|
||||
if resource_instance.type
|
||||
else [],
|
||||
resource_regions=(
|
||||
[resource_instance.region]
|
||||
if resource_instance.region
|
||||
else []
|
||||
),
|
||||
resource_services=(
|
||||
[resource_instance.service]
|
||||
if resource_instance.service
|
||||
else []
|
||||
),
|
||||
resource_types=(
|
||||
[resource_instance.type] if resource_instance.type else []
|
||||
),
|
||||
)
|
||||
findings_to_create.append(finding_instance)
|
||||
resource_denormalized_data.append(
|
||||
@@ -1013,6 +1235,7 @@ def _process_finding_micro_batch(
|
||||
if r is None:
|
||||
continue
|
||||
# Manually bump updated_at since bulk_update bypasses auto_now.
|
||||
snapshot_resource_fields(uid, r)
|
||||
r.updated_at = now_utc
|
||||
resources_to_bulk_update.append(r)
|
||||
if resources_to_bulk_update:
|
||||
@@ -1034,6 +1257,7 @@ def _process_finding_micro_batch(
|
||||
# Successful execution: leave deadlock retry loop.
|
||||
break
|
||||
except (OperationalError, IntegrityError) as db_err:
|
||||
restore_attempt_caches()
|
||||
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
|
||||
logger.warning(
|
||||
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
|
||||
@@ -1708,8 +1932,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
)
|
||||
|
||||
# Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
|
||||
# stays bounded; tally requirement_statuses in the same pass.
|
||||
# stays bounded; tally requirement_statuses in the same pass. The
|
||||
# ORM fallback re-iterates from scratch, so the tally resets first.
|
||||
def _iter_compliance_requirement_rows():
|
||||
requirement_statuses.clear()
|
||||
for region in regions:
|
||||
region_stats = region_requirement_stats.get(region, {})
|
||||
region_findings = findings_count_by_compliance.get(region, {})
|
||||
@@ -1773,12 +1999,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
"total_findings": total_findings,
|
||||
}
|
||||
|
||||
# Idempotent re-run: clear this scan's rows before re-inserting.
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
|
||||
|
||||
# The delete of the scan's previous rows happens inside the same
|
||||
# transaction as the inserts (see _copy_compliance_requirement_rows).
|
||||
requirements_created = _persist_compliance_requirement_rows(
|
||||
tenant_id, _iter_compliance_requirement_rows()
|
||||
tenant_id_str, scan_id_str, _iter_compliance_requirement_rows
|
||||
)
|
||||
|
||||
# Create pre-aggregated summaries for fast compliance overview lookups
|
||||
|
||||
@@ -11,6 +11,7 @@ from api.compliance import (
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import delete_related_daily_task, rls_transaction
|
||||
from api.decorators import handle_provider_deletion, set_tenant
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import (
|
||||
Finding,
|
||||
Integration,
|
||||
@@ -666,7 +667,13 @@ class AttackPathsScanRLSTask(RLSTask):
|
||||
scan_id = kwargs.get("scan_id")
|
||||
|
||||
if tenant_id and scan_id:
|
||||
logger.error(f"Attack paths scan task {task_id} failed: {exc}")
|
||||
if isinstance(exc, ProviderDeletedException):
|
||||
logger.warning(
|
||||
f"Attack paths scan task {task_id} stopped because its provider "
|
||||
f"or tenant was deleted: {exc}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Attack paths scan task {task_id} failed: {exc}")
|
||||
attack_paths_db_utils.fail_attack_paths_scan(tenant_id, scan_id, str(exc))
|
||||
|
||||
|
||||
@@ -790,12 +797,34 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
if name not in frameworks_bulk and universal_bulk[name].outputs
|
||||
}
|
||||
frameworks_avail = get_compliance_frameworks(provider_type)
|
||||
# Idempotency: a previous run of this task for the same scan may have left
|
||||
# output files behind (e.g. broker redelivery after a worker was killed
|
||||
# mid-run with task_acks_late, or a successful run on a deployment without
|
||||
# S3 where the tmp dir is not removed). Output writers open files in append
|
||||
# mode with a deterministic path (derived from scan.started_at), so reusing
|
||||
# them would append every finding row again and duplicate the CSV/output
|
||||
# rows. Start from a clean slate before (re)generating.
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
if os.path.exists(scan_tmp_dir):
|
||||
rmtree(scan_tmp_dir, ignore_errors=True)
|
||||
# The writers below open output files in append mode with deterministic
|
||||
# paths (derived from scan.started_at). Any stale file that survives the
|
||||
# cleanup would get every finding row appended again, which is the exact
|
||||
# duplication this guards against. Continuing is therefore unsafe: abort
|
||||
# so `ScanReportRLSTask.on_failure` removes the tmp dir and the retry
|
||||
# starts from a clean slate instead of publishing duplicated rows.
|
||||
if os.path.exists(scan_tmp_dir):
|
||||
raise RuntimeError(
|
||||
"Could not remove stale output directory for scan "
|
||||
f"{scan_id} before generating outputs; aborting to avoid "
|
||||
"duplicated rows in appended outputs."
|
||||
)
|
||||
|
||||
out_dir, comp_dir = _generate_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
|
||||
)
|
||||
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
|
||||
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
|
||||
def get_writer(writer_map, name, factory, is_last):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import neo4j.exceptions
|
||||
import pytest
|
||||
from tasks.jobs.attack_paths import aws
|
||||
|
||||
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
|
||||
|
||||
def _make_neo4j_error(code: str) -> neo4j.exceptions.Neo4jError:
|
||||
return neo4j.exceptions.Neo4jError._hydrate_neo4j(
|
||||
code=code,
|
||||
message="graph query failed",
|
||||
)
|
||||
|
||||
|
||||
def _resource_functions(failing_sync, following_sync):
|
||||
return {
|
||||
"failing_sync": failing_sync,
|
||||
"following_sync": following_sync,
|
||||
"permission_relationships": MagicMock(),
|
||||
"resourcegroupstaggingapi": MagicMock(),
|
||||
}
|
||||
|
||||
|
||||
def test_sync_aws_account_reraises_database_not_found_immediately():
|
||||
error = _make_neo4j_error(DATABASE_NOT_FOUND_CODE)
|
||||
failing_sync = MagicMock(side_effect=error)
|
||||
following_sync = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
aws.cartography_aws,
|
||||
"RESOURCE_FUNCTIONS",
|
||||
_resource_functions(failing_sync, following_sync),
|
||||
),
|
||||
patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
|
||||
patch.object(aws.utils, "stringify_exception") as stringify_exception,
|
||||
patch.object(aws.logger, "warning") as warning,
|
||||
pytest.raises(neo4j.exceptions.Neo4jError) as exc_info,
|
||||
):
|
||||
aws.sync_aws_account(
|
||||
SimpleNamespace(uid="123456789012"),
|
||||
[
|
||||
"failing_sync",
|
||||
"following_sync",
|
||||
"permission_relationships",
|
||||
"resourcegroupstaggingapi",
|
||||
],
|
||||
{},
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
assert exc_info.value is error
|
||||
following_sync.assert_not_called()
|
||||
stringify_exception.assert_not_called()
|
||||
warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
_make_neo4j_error("Neo.ClientError.Statement.SyntaxError"),
|
||||
RuntimeError("resource sync failed"),
|
||||
],
|
||||
ids=["different-neo4j-error", "non-neo4j-error"],
|
||||
)
|
||||
def test_sync_aws_account_warns_and_continues_for_other_exceptions(error):
|
||||
failing_sync = MagicMock(side_effect=error)
|
||||
following_sync = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
aws.cartography_aws,
|
||||
"RESOURCE_FUNCTIONS",
|
||||
_resource_functions(failing_sync, following_sync),
|
||||
),
|
||||
patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
|
||||
patch.object(
|
||||
aws.utils,
|
||||
"stringify_exception",
|
||||
return_value="formatted failure",
|
||||
),
|
||||
patch.object(aws.logger, "warning") as warning,
|
||||
):
|
||||
failed_syncs = aws.sync_aws_account(
|
||||
SimpleNamespace(uid="123456789012"),
|
||||
[
|
||||
"failing_sync",
|
||||
"following_sync",
|
||||
"permission_relationships",
|
||||
"resourcegroupstaggingapi",
|
||||
],
|
||||
{},
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
assert failed_syncs == {"failing_sync": "formatted failure"}
|
||||
following_sync.assert_called_once_with()
|
||||
warning.assert_called_once()
|
||||
assert "Continuing to the next AWS sync function" in warning.call_args.args[0]
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from contextlib import nullcontext
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
@@ -5,7 +6,9 @@ from unittest.mock import MagicMock, call, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import (
|
||||
AttackPathsScan,
|
||||
Finding,
|
||||
@@ -250,6 +253,32 @@ class TestAttackPathsRun:
|
||||
mock_starting.assert_not_called()
|
||||
mock_create_db.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ingestion_error", "temporary_database_missing"),
|
||||
[
|
||||
(RuntimeError("ingestion boom"), False),
|
||||
(
|
||||
GraphDatabaseQueryException(
|
||||
message="Graph not found: db-scan-id",
|
||||
code="Neo.ClientError.Database.DatabaseNotFound",
|
||||
),
|
||||
True,
|
||||
),
|
||||
(
|
||||
GraphDatabaseQueryException(
|
||||
message="Graph not found: db-tenant-id",
|
||||
code="Neo.ClientError.Database.DatabaseNotFound",
|
||||
),
|
||||
False,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"regular-error",
|
||||
"temporary-database-missing",
|
||||
"sink-database-missing",
|
||||
],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.logger")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
return_value="Cartography failed: ingestion boom",
|
||||
@@ -302,6 +331,9 @@ class TestAttackPathsRun:
|
||||
mock_drop_db,
|
||||
mock_event_loop,
|
||||
mock_stringify,
|
||||
mock_logger,
|
||||
ingestion_error,
|
||||
temporary_database_missing,
|
||||
tenants_fixture,
|
||||
aws_provider,
|
||||
scans_fixture,
|
||||
@@ -321,7 +353,11 @@ class TestAttackPathsRun:
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.return_value = mock_session
|
||||
session_ctx.__exit__.return_value = False
|
||||
ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom"))
|
||||
ingestion_fn = MagicMock(side_effect=ingestion_error)
|
||||
if temporary_database_missing:
|
||||
mock_finish.side_effect = DatabaseError(
|
||||
"Save with update_fields did not affect any rows"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -337,13 +373,28 @@ class TestAttackPathsRun:
|
||||
return_value=ingestion_fn,
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="ingestion boom"):
|
||||
with pytest.raises(type(ingestion_error)):
|
||||
attack_paths_run(str(tenant.id), str(scan.id), "task-456")
|
||||
|
||||
failure_args = mock_finish.call_args[0]
|
||||
assert failure_args[0] is attack_paths_scan
|
||||
assert failure_args[1] == StateChoices.FAILED
|
||||
assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"}
|
||||
mock_drop_db.assert_called_once_with("db-scan-id")
|
||||
if temporary_database_missing:
|
||||
mock_logger.warning.assert_any_call("Cartography failed: ingestion boom")
|
||||
mock_logger.exception.assert_not_called()
|
||||
mock_logger.log.assert_called_once_with(
|
||||
logging.WARNING,
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
|
||||
"(row may have been deleted): Save with update_fields did not affect "
|
||||
"any rows",
|
||||
exc_info=False,
|
||||
)
|
||||
else:
|
||||
mock_logger.exception.assert_called_once_with(
|
||||
"Cartography failed: ingestion boom"
|
||||
)
|
||||
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
@@ -1265,6 +1316,33 @@ class TestAttackPathsScanRLSTaskOnFailure:
|
||||
|
||||
mock_fail.assert_called_once_with("t-1", "s-1", "boom")
|
||||
|
||||
def test_on_failure_logs_provider_deletion_as_warning(self):
|
||||
from tasks.tasks import AttackPathsScanRLSTask
|
||||
|
||||
task = AttackPathsScanRLSTask()
|
||||
error = ProviderDeletedException("provider deleted")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.logger") as mock_logger,
|
||||
patch(
|
||||
"tasks.tasks.attack_paths_db_utils.fail_attack_paths_scan"
|
||||
) as mock_fail,
|
||||
):
|
||||
task.on_failure(
|
||||
exc=error,
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
"Attack paths scan task task-abc stopped because its provider or tenant "
|
||||
"was deleted: provider deleted"
|
||||
)
|
||||
mock_logger.error.assert_not_called()
|
||||
mock_fail.assert_called_once_with("t-1", "s-1", "provider deleted")
|
||||
|
||||
def test_on_failure_skips_when_missing_kwargs(self):
|
||||
from tasks.tasks import AttackPathsScanRLSTask
|
||||
|
||||
@@ -1896,7 +1974,7 @@ class TestSyncNodes:
|
||||
mock_source_1.run.return_value = [row]
|
||||
mock_source_2 = MagicMock()
|
||||
mock_source_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -1933,7 +2011,7 @@ class TestSyncNodes:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
sink.write_nodes.side_effect = lambda *_a, **_kw: call_order.append(
|
||||
"sink:write"
|
||||
)
|
||||
@@ -1969,18 +2047,15 @@ class TestSyncNodes:
|
||||
src_2.run.return_value = [row_b]
|
||||
src_3 = MagicMock()
|
||||
src_3.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
):
|
||||
result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, [])
|
||||
|
||||
@@ -2009,17 +2084,14 @@ class TestSyncNodes:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=2)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
):
|
||||
result = sync_module.sync_nodes(
|
||||
"src", "tgt", "t-1", "p-1", sink, normalized_lists
|
||||
@@ -2037,7 +2109,7 @@ class TestSyncNodes:
|
||||
def test_sync_nodes_empty_source_returns_zero(self):
|
||||
src = MagicMock()
|
||||
src.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -2066,7 +2138,7 @@ class TestSyncRelationships:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
sink.write_relationships.side_effect = lambda *_a, **_kw: call_order.append(
|
||||
"sink:write"
|
||||
)
|
||||
@@ -2104,18 +2176,15 @@ class TestSyncRelationships:
|
||||
src_2.run.return_value = [row_b]
|
||||
src_3 = MagicMock()
|
||||
src_3.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
):
|
||||
total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
|
||||
|
||||
@@ -2140,17 +2209,14 @@ class TestSyncRelationships:
|
||||
src_1.run.return_value = rows
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=2)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
):
|
||||
total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
|
||||
|
||||
@@ -2163,7 +2229,7 @@ class TestSyncRelationships:
|
||||
def test_sync_relationships_empty_source_returns_zero(self):
|
||||
src = MagicMock()
|
||||
src.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -3056,6 +3122,61 @@ class TestCleanupStaleAttackPathsScans:
|
||||
ap_scan.refresh_from_db()
|
||||
assert ap_scan.state == StateChoices.FAILED
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("age_seconds", "should_clean"),
|
||||
[
|
||||
(960 * 60 - 1, False),
|
||||
(960 * 60, False),
|
||||
(960 * 60 + 1, True),
|
||||
],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.cleanup.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.cleanup._revoke_task")
|
||||
@patch("tasks.jobs.attack_paths.cleanup._ping_workers")
|
||||
def test_stale_threshold_boundary_is_strict(
|
||||
self,
|
||||
mock_ping,
|
||||
mock_revoke,
|
||||
mock_drop_db,
|
||||
mock_recover,
|
||||
age_seconds,
|
||||
should_clean,
|
||||
tenants_fixture,
|
||||
aws_provider,
|
||||
):
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
ap_scan, task_result = self._create_executing_scan(
|
||||
tenants_fixture[0],
|
||||
aws_provider,
|
||||
started_at=now - timedelta(seconds=age_seconds),
|
||||
worker="live-worker@host",
|
||||
)
|
||||
mock_ping.return_value = ({"live-worker@host"}, set())
|
||||
|
||||
with patch("tasks.jobs.attack_paths.cleanup.datetime") as mock_datetime:
|
||||
mock_datetime.now.return_value = now
|
||||
result = cleanup_stale_attack_paths_scans()
|
||||
|
||||
assert result["cleaned_up_count"] == int(should_clean)
|
||||
ap_scan.refresh_from_db()
|
||||
expected_state = StateChoices.FAILED if should_clean else StateChoices.EXECUTING
|
||||
assert ap_scan.state == expected_state
|
||||
if should_clean:
|
||||
mock_revoke.assert_called_once_with(task_result, terminate=True)
|
||||
mock_drop_db.assert_called_once()
|
||||
mock_recover.assert_called_once()
|
||||
else:
|
||||
mock_revoke.assert_not_called()
|
||||
mock_drop_db.assert_not_called()
|
||||
mock_recover.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
|
||||
@patch(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -671,6 +672,8 @@ class TestSecurityHubIntegrationUploads:
|
||||
mock_integration = MagicMock()
|
||||
mock_integration.configuration = {"send_only_fails": True}
|
||||
mock_integration.credentials = {} # Empty credentials, use provider
|
||||
mock_integration.connected = False
|
||||
mock_integration.connection_last_checked_at = None
|
||||
|
||||
# Mock tenant_id
|
||||
tenant_id = "550e8400-e29b-41d4-a716-446655440000" # Valid UUID
|
||||
@@ -723,12 +726,22 @@ class TestSecurityHubIntegrationUploads:
|
||||
# Configure the test_connection to return our mock_connection
|
||||
mock_security_hub_class.test_connection = mock_test_connection
|
||||
|
||||
checked_at_before = datetime.now(tz=UTC)
|
||||
connected, security_hub = get_security_hub_client_from_integration(
|
||||
mock_integration, tenant_id, mock_findings
|
||||
)
|
||||
checked_at_after = datetime.now(tz=UTC)
|
||||
|
||||
assert connected is True
|
||||
assert security_hub == mock_security_hub
|
||||
assert mock_integration.connected is True
|
||||
assert mock_integration.connection_last_checked_at.tzinfo is UTC
|
||||
assert (
|
||||
checked_at_before
|
||||
<= mock_integration.connection_last_checked_at
|
||||
<= checked_at_after
|
||||
)
|
||||
mock_integration.save.assert_called_once()
|
||||
|
||||
# Verify SecurityHub was called once to create the client
|
||||
assert mock_security_hub_class.call_count == 1
|
||||
|
||||
@@ -420,6 +420,124 @@ class TestGenerateOutputs:
|
||||
assert result == {"upload": False}
|
||||
mock_scan_update.return_value.update.assert_called_once()
|
||||
|
||||
def test_generate_outputs_removes_previous_run_artifacts(self):
|
||||
"""Regression for PROWLER-2266.
|
||||
|
||||
Output writers open files in append mode with a deterministic path
|
||||
(derived from scan.started_at). If this task runs again for the same
|
||||
scan (e.g. broker redelivery after a worker is killed mid-run with
|
||||
task_acks_late), reusing the leftover files appends every finding row
|
||||
again, duplicating rows in the CSV/output while the API console keeps
|
||||
showing a single finding. The task must start from a clean slate by
|
||||
removing the scan's tmp output directory before (re)generating.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_root:
|
||||
# Simulate artifacts left behind by a previous run of the same scan.
|
||||
scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
|
||||
scan_tmp_dir.mkdir(parents=True)
|
||||
stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
|
||||
stale_artifact.write_text("HEADER\nold-finding-row\n")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.tasks.Provider.objects.get"),
|
||||
patch("tasks.tasks.initialize_prowler_provider"),
|
||||
patch("tasks.tasks.Compliance.get_bulk"),
|
||||
patch("tasks.tasks.get_compliance_frameworks"),
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
patch("tasks.tasks.Finding.all_objects.filter") as mock_findings,
|
||||
patch(
|
||||
"tasks.tasks._generate_output_directory",
|
||||
return_value=("/tmp/test/out", "/tmp/test/comp"),
|
||||
),
|
||||
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
|
||||
patch("tasks.tasks.FindingOutput.transform_api_finding"),
|
||||
patch(
|
||||
"tasks.tasks.OUTPUT_FORMATS_MAPPING",
|
||||
{
|
||||
"json": {
|
||||
"class": MagicMock(name="Writer"),
|
||||
"suffix": ".json",
|
||||
"kwargs": {},
|
||||
}
|
||||
},
|
||||
),
|
||||
patch("tasks.tasks.COMPLIANCE_CLASS_MAP", {"aws": []}),
|
||||
patch(
|
||||
"tasks.tasks._compress_output_files", return_value="/tmp/compressed"
|
||||
),
|
||||
patch("tasks.tasks._upload_to_s3", return_value=None),
|
||||
patch("tasks.tasks.Scan.all_objects.filter"),
|
||||
):
|
||||
mock_filter.return_value.exists.return_value = True
|
||||
mock_findings.return_value.order_by.return_value.iterator.return_value = [
|
||||
[MagicMock()],
|
||||
True,
|
||||
]
|
||||
|
||||
generate_outputs_task(
|
||||
scan_id=self.scan_id,
|
||||
provider_id=self.provider_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
# The stale artifacts from the previous run must be gone, so the
|
||||
# append-mode writers cannot duplicate rows onto them.
|
||||
assert not stale_artifact.exists()
|
||||
assert not scan_tmp_dir.exists()
|
||||
|
||||
def test_generate_outputs_aborts_when_stale_cleanup_fails(self):
|
||||
"""Regression for PROWLER-2266.
|
||||
|
||||
If the stale output directory cannot be removed (e.g. permission error),
|
||||
the leftover files would be reopened in append mode and every finding
|
||||
row would be duplicated. The task must abort instead of continuing and
|
||||
publishing duplicated rows, so the retry can start from a clean slate.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_root:
|
||||
scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
|
||||
scan_tmp_dir.mkdir(parents=True)
|
||||
stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
|
||||
stale_artifact.write_text("HEADER\nold-finding-row\n")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.tasks.Provider.objects.get"),
|
||||
patch("tasks.tasks.initialize_prowler_provider"),
|
||||
patch("tasks.tasks.Compliance.get_bulk"),
|
||||
patch("tasks.tasks.get_compliance_frameworks"),
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
# `rmtree(ignore_errors=True)` swallows the failure and leaves the
|
||||
# directory behind; simulate that with a no-op so the guard fires.
|
||||
patch("tasks.tasks.rmtree"),
|
||||
patch("tasks.tasks._generate_output_directory") as mock_gen_dir,
|
||||
patch("tasks.tasks._compress_output_files") as mock_compress,
|
||||
patch("tasks.tasks._upload_to_s3") as mock_upload,
|
||||
patch("tasks.tasks.Scan.all_objects.filter") as mock_scan_update,
|
||||
):
|
||||
mock_filter.return_value.exists.return_value = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="stale output directory"):
|
||||
generate_outputs_task(
|
||||
scan_id=self.scan_id,
|
||||
provider_id=self.provider_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
# The task must abort before generating/publishing any output.
|
||||
mock_gen_dir.assert_not_called()
|
||||
mock_compress.assert_not_called()
|
||||
mock_upload.assert_not_called()
|
||||
mock_scan_update.assert_not_called()
|
||||
|
||||
def test_generate_outputs_triggers_html_extra_update(self):
|
||||
mock_finding_output = MagicMock()
|
||||
mock_finding_output.compliance = {"cis": ["requirement-1", "requirement-2"]}
|
||||
@@ -3304,6 +3422,7 @@ class TestTaskTimeLimits:
|
||||
for name in (
|
||||
"scan-perform",
|
||||
"scan-perform-scheduled",
|
||||
"attack-paths-scan-perform",
|
||||
"provider-deletion",
|
||||
"tenant-deletion",
|
||||
):
|
||||
|
||||
@@ -4673,8 +4673,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.32.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#5dac8a0a53272e4db68c476fb969dc03e88beb68" }
|
||||
version = "5.36.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.36#2298d4a3f881abe9e2266195f764b60d34af07eb" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4762,7 +4762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.35.0"
|
||||
version = "1.37.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -4862,7 +4862,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.36" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "Prowler API key",
|
||||
"description": "API key token used to authenticate with Prowler Cloud / Prowler App via the Prowler MCP server. Create one at https://cloud.prowler.com.",
|
||||
"description": "API key token used to authenticate with Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server) via the Prowler MCP server. Create one at https://cloud.prowler.com.",
|
||||
"sensitive": true,
|
||||
"required": true
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ If the framework is not supported, tell the user, suggest they request it or con
|
||||
|
||||
### 1.1 Connect to Prowler Cloud
|
||||
|
||||
Verify the Prowler MCP connection by calling `prowler_app_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
|
||||
Verify the Prowler MCP connection by calling `prowler_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
|
||||
For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation.
|
||||
|
||||
### 1.2 Verify the provider is configured (or configure it)
|
||||
|
||||
Call `prowler_app_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
|
||||
Call `prowler_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
|
||||
|
||||
- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`.
|
||||
- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`.
|
||||
@@ -57,15 +57,15 @@ Call `prowler_app_search_providers` to check whether the target provider (AWS ac
|
||||
|
||||
The flow needs at least one completed scan with a compliance report available.
|
||||
|
||||
Look for a completed scan first: call `prowler_app_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_app_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
|
||||
Look for a completed scan first: call `prowler_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
|
||||
|
||||
If no completed scan has a report, call `prowler_app_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
|
||||
If no completed scan has a report, call `prowler_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
|
||||
|
||||
> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)*
|
||||
>
|
||||
> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer.
|
||||
|
||||
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_app_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
|
||||
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
|
||||
|
||||
When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results.
|
||||
|
||||
@@ -85,7 +85,7 @@ Status taxonomy for failed requirements and their findings:
|
||||
|
||||
### Report template
|
||||
|
||||
A fresh report is rendered like this (substituting values from the `prowler_app_get_compliance_framework_state_details` Prowler MCP tool response):
|
||||
A fresh report is rendered like this (substituting values from the `prowler_get_compliance_framework_state_details` Prowler MCP tool response):
|
||||
|
||||
````markdown
|
||||
# Compliance report: <compliance_id>
|
||||
@@ -120,7 +120,7 @@ A fresh report is rendered like this (substituting values from the `prowler_app_
|
||||
|
||||
Resolve the report path for the current `compliance_id` and provider account.
|
||||
|
||||
If the file does not exist, call `prowler_app_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
|
||||
If the file does not exist, call `prowler_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
|
||||
|
||||
If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat.
|
||||
|
||||
@@ -128,7 +128,7 @@ If the file exists, read it and compare its `Scan ID` to the target scan from se
|
||||
>
|
||||
> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer.
|
||||
|
||||
On confirmation, regenerate the failed-requirements section from the new `prowler_app_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
|
||||
On confirmation, regenerate the failed-requirements section from the new `prowler_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
|
||||
|
||||
Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list.
|
||||
|
||||
@@ -174,7 +174,7 @@ Once approved, the loop proceeds through the batch without further prompts unles
|
||||
|
||||
Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done.
|
||||
|
||||
Call `prowler_app_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
|
||||
Call `prowler_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
|
||||
|
||||
If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2.
|
||||
|
||||
@@ -194,6 +194,6 @@ Move to the next `[FAIL]` requirement and repeat from section 3.3.
|
||||
|
||||
> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)*
|
||||
>
|
||||
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_app_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
|
||||
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
|
||||
|
||||
On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log.
|
||||
|
||||
@@ -36,6 +36,9 @@ The former build-time variables map to the new runtime variables as follows:
|
||||
| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
|
||||
| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
|
||||
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
|
||||
| `NEXT_PUBLIC_IS_CLOUD_ENV` | `UI_CLOUD_ENABLED` |
|
||||
|
||||
`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
|
||||
|
||||
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration.
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ The MCP client manages connections to the Prowler MCP Server using a singleton p
|
||||
|
||||
- **Connection Management**: Retry logic with configurable attempts and delays
|
||||
- **Tool Discovery**: Fetches available tools from MCP server on initialization
|
||||
- **Authentication Injection**: Automatically adds JWT tokens to `prowler_app_*` tool calls
|
||||
- **Authentication Injection**: Automatically adds JWT tokens to `prowler_*` tool calls
|
||||
- **Reconnection**: Supports forced reconnection after server restarts
|
||||
|
||||
Key constants:
|
||||
@@ -141,10 +141,14 @@ Key constants:
|
||||
- `RECONNECT_INTERVAL_MS`: 5 minutes before retry after failure
|
||||
|
||||
```typescript
|
||||
// Authentication injection for prowler_app tools
|
||||
// Authentication injection for core prowler_ tools (Hub/Docs excluded)
|
||||
private handleBeforeToolCall = ({ name, args }) => {
|
||||
// Only inject auth for prowler_app_* tools (user-specific data)
|
||||
if (!name.startsWith("prowler_app_")) {
|
||||
// Only inject auth for prowler_* tools (user-specific data).
|
||||
// The legacy prowler_app_ prefix is also accepted for a resilient rollout.
|
||||
if (
|
||||
!name.startsWith("prowler_") &&
|
||||
!name.startsWith("prowler_app_")
|
||||
) {
|
||||
return { args };
|
||||
}
|
||||
|
||||
@@ -307,7 +311,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
|
||||
|
||||
| Namespace | Auth Required | Description |
|
||||
|-----------|---------------|-------------|
|
||||
| `prowler_app_*` | Yes (JWT) | Prowler Cloud and Prowler Local Server tools for findings, providers, scans, resources |
|
||||
| `prowler_*` | Yes (JWT) | Prowler Cloud, Prowler Private Cloud, and Prowler Local Server tools for findings, providers, scans, resources |
|
||||
| `prowler_hub_*` | No | Security checks catalog, compliance frameworks |
|
||||
| `prowler_docs_*` | No | Documentation search and retrieval |
|
||||
|
||||
@@ -315,7 +319,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
|
||||
|
||||
1. User authenticates with Prowler Local Server, receiving a JWT token
|
||||
2. Token is stored in session and propagated via `authContextStorage`
|
||||
3. MCP client injects `Authorization: Bearer <token>` header for `prowler_app_*` calls
|
||||
3. MCP client injects `Authorization: Bearer <token>` header for `prowler_*` calls
|
||||
4. MCP Server validates token and applies RLS filtering
|
||||
|
||||
### Tool Execution Pattern
|
||||
@@ -323,7 +327,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
|
||||
The agent uses meta-tools rather than direct tool registration:
|
||||
|
||||
```
|
||||
Agent needs data → describe_tool("prowler_app_search_findings")
|
||||
Agent needs data → describe_tool("prowler_search_findings")
|
||||
→ Returns parameter schema → execute_tool with parameters
|
||||
→ MCP client adds auth header → MCP Server executes
|
||||
→ Results returned to agent → Agent continues reasoning
|
||||
|
||||
@@ -18,11 +18,15 @@ The Prowler MCP Server brings the entire Prowler ecosystem to AI assistants thro
|
||||
|
||||
The server follows a modular architecture with three independent sub-servers:
|
||||
|
||||
| Sub-Server | Auth Required | Description |
|
||||
|------------|---------------|-------------|
|
||||
| `prowler_app` | Yes | Full access to Prowler Cloud and Prowler Local Server features |
|
||||
| Prowler Hub | No | Security checks catalog with **over 2,000 checks**, fixers, and **70+ compliance frameworks** |
|
||||
| Prowler Documentation | No | Full-text search and retrieval of official documentation |
|
||||
| Sub-Server | Tool Prefix | Auth Required | Description |
|
||||
|------------|-------------|---------------|-------------|
|
||||
| Prowler | `prowler_` | Yes | Full access to Prowler Cloud, Prowler Private Cloud, and Prowler Local Server features |
|
||||
| Prowler Hub | `prowler_hub_` | No | Security checks catalog with **over 2,000 checks**, fixers, and **70+ compliance frameworks** |
|
||||
| Prowler Documentation | `prowler_docs_` | No | Full-text search and retrieval of official documentation |
|
||||
|
||||
<Note>
|
||||
The core Prowler sub-server is served under the `prowler_` tool prefix, while its source lives in the `prowler_app/` module for historical reasons. Tool names use the prefix; import paths use the module.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
For a complete list of tools and their descriptions, see the [Tools Reference](/getting-started/basic-usage/prowler-mcp-tools).
|
||||
@@ -413,7 +417,7 @@ uv run prowler-mcp
|
||||
uv run prowler-mcp --transport http --host 0.0.0.0 --port 8000
|
||||
|
||||
# Run with environment variables
|
||||
PROWLER_APP_API_KEY="pk_xxx" uv run prowler-mcp
|
||||
PROWLER_API_KEY="pk_xxx" uv run prowler-mcp
|
||||
```
|
||||
|
||||
For complete installation and deployment options, see:
|
||||
|
||||
@@ -72,15 +72,32 @@
|
||||
"group": "Prowler MCP",
|
||||
"pages": [
|
||||
"getting-started/products/prowler-mcp",
|
||||
"getting-started/installation/prowler-mcp",
|
||||
"getting-started/basic-usage/prowler-mcp",
|
||||
"getting-started/basic-usage/prowler-mcp-tools"
|
||||
"getting-started/basic-usage/prowler-mcp-tools",
|
||||
"getting-started/installation/prowler-mcp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Prowler for AI Agents",
|
||||
"pages": [
|
||||
"getting-started/products/prowler-claude-code-plugin"
|
||||
"user-guide/ai-agents/index",
|
||||
"user-guide/ai-agents/claude-code",
|
||||
"user-guide/ai-agents/claude-desktop",
|
||||
"user-guide/ai-agents/codex",
|
||||
"user-guide/ai-agents/cursor",
|
||||
"user-guide/ai-agents/vscode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Prowler for MSPs and MSSPs",
|
||||
"pages": [
|
||||
"getting-started/products/prowler-for-msps",
|
||||
"user-guide/tutorials/prowler-for-msps-sign-up",
|
||||
"user-guide/tutorials/prowler-for-msps-organization",
|
||||
"user-guide/tutorials/prowler-for-msps-team",
|
||||
"user-guide/tutorials/prowler-for-msps-customers",
|
||||
"user-guide/tutorials/prowler-for-msps-billing",
|
||||
"user-guide/tutorials/prowler-for-msps-branding"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -195,6 +212,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Prowler for MSPs and MSSPs",
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-for-msps-sign-up",
|
||||
"user-guide/tutorials/prowler-for-msps-organization",
|
||||
"user-guide/tutorials/prowler-for-msps-team",
|
||||
"user-guide/tutorials/prowler-for-msps-customers",
|
||||
"user-guide/tutorials/prowler-for-msps-billing",
|
||||
"user-guide/tutorials/prowler-for-msps-branding"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Prowler Lighthouse AI",
|
||||
"pages": [
|
||||
@@ -217,7 +245,12 @@
|
||||
{
|
||||
"group": "Prowler for AI Agents",
|
||||
"pages": [
|
||||
"getting-started/products/prowler-claude-code-plugin"
|
||||
"user-guide/ai-agents/index",
|
||||
"user-guide/ai-agents/claude-code",
|
||||
"user-guide/ai-agents/claude-desktop",
|
||||
"user-guide/ai-agents/codex",
|
||||
"user-guide/ai-agents/cursor",
|
||||
"user-guide/ai-agents/vscode"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -660,6 +693,10 @@
|
||||
{
|
||||
"source": "/user-guide/tutorials/prowler-cloud-public-ips",
|
||||
"destination": "/security/networking"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/products/prowler-claude-code-plugin",
|
||||
"destination": "/user-guide/ai-agents/claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|
||||
|----------|------------|------------------------|
|
||||
| Prowler Hub | 10 tools | No |
|
||||
| Prowler Documentation | 2 tools | No |
|
||||
| Prowler Cloud & Prowler Local Server | 32 tools | Yes |
|
||||
| Prowler Cloud, Private Cloud & Local Server | 32 tools | Yes |
|
||||
|
||||
## Tool Naming Convention
|
||||
|
||||
@@ -18,11 +18,11 @@ All tools follow a consistent naming pattern with prefixes:
|
||||
|
||||
- `prowler_hub_*` - Prowler Hub catalog and compliance tools
|
||||
- `prowler_docs_*` - Prowler documentation search and retrieval
|
||||
- `prowler_app_*` - Prowler Cloud and App (Self-Managed) management tools
|
||||
- `prowler_*` - Prowler Cloud, Prowler Private Cloud & Prowler Local Server management tools
|
||||
|
||||
## Prowler Cloud and Prowler Local Server Tools
|
||||
## Prowler Tools
|
||||
|
||||
Manage Prowler Cloud or Prowler Local Server features. **Requires authentication.**
|
||||
Manage your Prowler deployment — Prowler Cloud, Prowler Private Cloud, or Prowler Local Server. **Requires authentication.**
|
||||
|
||||
<Note>
|
||||
These tools require a valid API key. See the [Configuration Guide](/getting-started/basic-usage/prowler-mcp) for authentication setup.
|
||||
@@ -32,44 +32,44 @@ These tools require a valid API key. See the [Configuration Guide](/getting-star
|
||||
|
||||
Tools for searching, viewing, and analyzing security findings across all cloud providers.
|
||||
|
||||
- **`prowler_app_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status)
|
||||
- **`prowler_app_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
|
||||
- **`prowler_app_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
|
||||
- **`prowler_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status)
|
||||
- **`prowler_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
|
||||
- **`prowler_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
|
||||
|
||||
### Finding Groups Management
|
||||
|
||||
Tools for listing finding groups aggregated by check ID, viewing complete group counters, and drilling down into affected resources.
|
||||
|
||||
- **`prowler_app_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
|
||||
- **`prowler_app_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
|
||||
- **`prowler_app_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
|
||||
- **`prowler_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
|
||||
- **`prowler_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
|
||||
- **`prowler_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
|
||||
|
||||
### Provider Management
|
||||
|
||||
Tools for managing cloud provider connections in Prowler.
|
||||
|
||||
- **`prowler_app_search_providers`** - Search and view configured providers with their connection status
|
||||
- **`prowler_app_connect_provider`** - Register and connect a provider with credentials for security scanning
|
||||
- **`prowler_app_delete_provider`** - Permanently remove a provider from Prowler
|
||||
- **`prowler_search_providers`** - Search and view configured providers with their connection status
|
||||
- **`prowler_connect_provider`** - Register and connect a provider with credentials for security scanning
|
||||
- **`prowler_delete_provider`** - Permanently remove a provider from Prowler
|
||||
|
||||
### Scan Management
|
||||
|
||||
Tools for managing and monitoring security scans.
|
||||
|
||||
- **`prowler_app_list_scans`** - List and filter security scans across all providers
|
||||
- **`prowler_app_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts)
|
||||
- **`prowler_app_trigger_scan`** - Trigger a manual security scan for a provider
|
||||
- **`prowler_app_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring
|
||||
- **`prowler_app_update_scan`** - Update scan name for better organization
|
||||
- **`prowler_list_scans`** - List and filter security scans across all providers
|
||||
- **`prowler_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts)
|
||||
- **`prowler_trigger_scan`** - Trigger a manual security scan for a provider
|
||||
- **`prowler_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring
|
||||
- **`prowler_update_scan`** - Update scan name for better organization
|
||||
|
||||
### Resources Management
|
||||
|
||||
Tools for searching, viewing, and analyzing cloud resources discovered by Prowler.
|
||||
|
||||
- **`prowler_app_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
|
||||
- **`prowler_app_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
|
||||
- **`prowler_app_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
|
||||
- **`prowler_app_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
|
||||
- **`prowler_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
|
||||
- **`prowler_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
|
||||
- **`prowler_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
|
||||
- **`prowler_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
|
||||
|
||||
### Muting Management
|
||||
|
||||
@@ -77,33 +77,33 @@ Tools for managing finding muting, including pattern-based bulk muting (mutelist
|
||||
|
||||
#### Mutelist (Pattern-Based Muting)
|
||||
|
||||
- **`prowler_app_get_mutelist`** - Retrieve the current mutelist configuration for the tenant
|
||||
- **`prowler_app_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting
|
||||
- **`prowler_app_delete_mutelist`** - Remove the mutelist configuration from the tenant
|
||||
- **`prowler_get_mutelist`** - Retrieve the current mutelist configuration for the tenant
|
||||
- **`prowler_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting
|
||||
- **`prowler_delete_mutelist`** - Remove the mutelist configuration from the tenant
|
||||
|
||||
#### Mute Rules (Finding-Specific Muting)
|
||||
|
||||
- **`prowler_app_list_mute_rules`** - Search and filter mute rules with pagination support
|
||||
- **`prowler_app_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule
|
||||
- **`prowler_app_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail
|
||||
- **`prowler_app_update_mute_rule`** - Update a mute rule's name, reason, or enabled status
|
||||
- **`prowler_app_delete_mute_rule`** - Delete a mute rule from the system
|
||||
- **`prowler_list_mute_rules`** - Search and filter mute rules with pagination support
|
||||
- **`prowler_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule
|
||||
- **`prowler_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail
|
||||
- **`prowler_update_mute_rule`** - Update a mute rule's name, reason, or enabled status
|
||||
- **`prowler_delete_mute_rule`** - Delete a mute rule from the system
|
||||
|
||||
### Attack Paths Analysis
|
||||
|
||||
Tools for analyzing privilege escalation chains and security misconfigurations using graph-based analysis. Attack Paths maps relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited.
|
||||
|
||||
- **`prowler_app_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
|
||||
- **`prowler_app_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
|
||||
- **`prowler_app_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
|
||||
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
|
||||
- **`prowler_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
|
||||
- **`prowler_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
|
||||
- **`prowler_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
|
||||
- **`prowler_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
|
||||
|
||||
### Compliance Management
|
||||
|
||||
Tools for viewing compliance status and framework details across all cloud providers.
|
||||
|
||||
- **`prowler_app_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
|
||||
- **`prowler_app_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
|
||||
- **`prowler_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
|
||||
- **`prowler_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
|
||||
|
||||
## Prowler Hub Tools
|
||||
|
||||
@@ -145,7 +145,7 @@ Search and access official Prowler documentation. **No authentication required.*
|
||||
- Use natural language to interact with the tools through your AI assistant
|
||||
- Tools can be combined for complex workflows
|
||||
- Filter options are available on most list tools
|
||||
- Authentication is only required for Prowler Cloud and Prowler Local Server tools
|
||||
- Authentication is only required for Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ Configure your MCP client to connect to Prowler MCP Server.
|
||||
## Step 1: Get Your API Key
|
||||
|
||||
<Note>
|
||||
**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler Cloud and Prowler Local Server features.
|
||||
**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
|
||||
</Note>
|
||||
|
||||
To use Prowler Cloud or Prowler Local Server features. To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
|
||||
An API key authenticates the Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server). To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
|
||||
|
||||
<Warning>
|
||||
Keep the API key secure. Never share it publicly or commit it to version control.
|
||||
@@ -18,12 +18,36 @@ Keep the API key secure. Never share it publicly or commit it to version control
|
||||
|
||||
## Step 2: Configure Your MCP Host/Client
|
||||
|
||||
Choose the configuration based on your deployment:
|
||||
Most users should use the **Cloud MCP Server** — it needs no installation and is maintained by Prowler. The [Local MCP Server](#local-mcp-server-configuration) configuration is provided afterwards for users who run the server themselves.
|
||||
|
||||
- **HTTP Mode**: Prowler Cloud MCP Server or self-hosted Prowler MCP Server.
|
||||
- **STDIO Mode**: Local installation only (runs as subprocess of your MCP client).
|
||||
- **Cloud MCP Server (HTTP)**: the managed server at `https://mcp.prowler.com/mcp` (or your own self-hosted HTTP server).
|
||||
- **Local MCP Server (STDIO)**: local installation only (runs as a subprocess of your MCP client).
|
||||
|
||||
### HTTP Mode
|
||||
### Step-by-Step Guides Per Agent
|
||||
|
||||
The tabs below are a quick configuration reference. For a walkthrough with screenshots, troubleshooting, and client-specific caveats, follow the dedicated guide for your agent:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Claude Code" icon="terminal" href="/user-guide/ai-agents/claude-code">
|
||||
Plugin vs. MCP-only, and which Claude surfaces work
|
||||
</Card>
|
||||
<Card title="Claude Desktop App (Chat)" icon="comment" href="/user-guide/ai-agents/claude-desktop">
|
||||
The Chat tab, via a local bridge
|
||||
</Card>
|
||||
<Card title="Codex" icon="code" href="/user-guide/ai-agents/codex">
|
||||
CLI and the VS Code extension
|
||||
</Card>
|
||||
<Card title="Cursor" icon="arrow-pointer" href="/user-guide/ai-agents/cursor">
|
||||
Global and project scopes
|
||||
</Card>
|
||||
<Card title="VS Code / Copilot" icon="microsoft" href="/user-guide/ai-agents/vscode">
|
||||
Agent mode with secure key prompts
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Cloud MCP Server Configuration (Recommended)
|
||||
|
||||
Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP. This is the recommended path — no installation, always up to date. The same configuration works for a self-hosted HTTP server: just swap the URL.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Generic Native HTTP Support">
|
||||
@@ -61,10 +85,10 @@ Choose the configuration based on your deployment:
|
||||
"args": [
|
||||
"https://mcp.prowler.com/mcp", // or your self-hosted Prowler MCP Server URL
|
||||
"--header",
|
||||
"Authorization: Bearer ${PROWLER_APP_API_KEY}"
|
||||
"Authorization: Bearer ${PROWLER_API_KEY}"
|
||||
],
|
||||
"env": {
|
||||
"PROWLER_APP_API_KEY": "<your-api-key-here>"
|
||||
"PROWLER_API_KEY": "<your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,72 +98,11 @@ Choose the configuration based on your deployment:
|
||||
The `mcp-remote` tool acts as a bridge for clients that don't support HTTP natively. Learn more at [mcp-remote on npm](https://www.npmjs.com/package/mcp-remote).
|
||||
</Info>
|
||||
</Tab>
|
||||
|
||||
<Tab title="Claude Desktop">
|
||||
1. Open Claude Desktop settings
|
||||
2. Go to "Developer" tab
|
||||
3. Click in "Edit Config" button
|
||||
4. Edit the `claude_desktop_config.json` file with your favorite editor
|
||||
5. Install a reviewed version of `mcp-remote` in a dedicated local workspace:
|
||||
```bash
|
||||
mkdir -p ~/.local/share/prowler-mcp-bridge
|
||||
cd ~/.local/share/prowler-mcp-bridge
|
||||
npm init -y
|
||||
npm install --save-exact mcp-remote@0.1.38
|
||||
```
|
||||
6. Add the following configuration:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
|
||||
"args": [
|
||||
"https://mcp.prowler.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer ${PROWLER_APP_API_KEY}"
|
||||
],
|
||||
"env": {
|
||||
"PROWLER_APP_API_KEY": "<your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Claude Code">
|
||||
Run the following command:
|
||||
```bash
|
||||
export PROWLER_APP_API_KEY="<your-api-key-here>"
|
||||
claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_APP_API_KEY" --scope user
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Cursor">
|
||||
1. Open Cursor settings
|
||||
2. Go to "Tools & MCP"
|
||||
3. Click in "New MCP Server" button
|
||||
4. Add to the JSON Configuration the following:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"url": "https://mcp.prowler.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
### STDIO Mode
|
||||
## Local MCP Server Configuration
|
||||
|
||||
STDIO mode is only available when running the MCP server locally.
|
||||
STDIO mode is only available when running the **Local MCP Server** on your own machine. See the [Installation guide](/getting-started/installation/prowler-mcp) to set it up first.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Generic uvx installation">
|
||||
@@ -152,7 +115,7 @@ STDIO mode is only available when running the MCP server locally.
|
||||
"command": "uvx",
|
||||
"args": ["/absolute/path/to/prowler/mcp_server/"],
|
||||
"env": {
|
||||
"PROWLER_APP_API_KEY": "<your-api-key-here>",
|
||||
"PROWLER_API_KEY": "<your-api-key-here>",
|
||||
"API_BASE_URL": "https://api.prowler.com/api/v1"
|
||||
}
|
||||
}
|
||||
@@ -179,7 +142,7 @@ STDIO mode is only available when running the MCP server locally.
|
||||
"--rm",
|
||||
"-i",
|
||||
"--env",
|
||||
"PROWLER_APP_API_KEY=<your-api-key-here>",
|
||||
"PROWLER_API_KEY=<your-api-key-here>",
|
||||
"--env",
|
||||
"API_BASE_URL=https://api.prowler.com/api/v1",
|
||||
"prowlercloud/prowler-mcp"
|
||||
@@ -205,7 +168,7 @@ Restart your MCP client and start asking questions:
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
Prowler MCP Server supports two authentication methods to connect to Prowler Cloud or Prowler Local Server:
|
||||
Prowler MCP Server supports two authentication methods to connect to Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server):
|
||||
|
||||
### API Key (Recommended)
|
||||
|
||||
|
||||
@@ -128,8 +128,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.33.0"
|
||||
PROWLER_API_VERSION="5.33.0"
|
||||
PROWLER_UI_VERSION="5.35.0"
|
||||
PROWLER_API_VERSION="5.35.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -5,12 +5,12 @@ title: "Installation"
|
||||
There are **two ways** to use Prowler MCP Server:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Option 1: Managed by Prowler" icon="cloud" color="#10B981">
|
||||
<Card title="Cloud MCP Server (Recommended)" icon="cloud" color="#10B981">
|
||||
**No installation required** - Just configuration
|
||||
|
||||
Use `https://mcp.prowler.com/mcp`
|
||||
</Card>
|
||||
<Card title="Option 2: Run Locally" icon="server" color="#6366F1">
|
||||
<Card title="Local MCP Server" icon="server" color="#6366F1">
|
||||
**Local installation** - Full control
|
||||
|
||||
Install via Docker, PyPI, or source code
|
||||
@@ -18,8 +18,8 @@ There are **two ways** to use Prowler MCP Server:
|
||||
</CardGroup>
|
||||
|
||||
|
||||
For "Option 1: Managed by Prowler", go directly to the [Configuration Guide](/getting-started/basic-usage/prowler-mcp#hosted-server-configuration-recommended) to set up your Claude Desktop, Cursor, or other MCP client.
|
||||
**This guide is focused on local installation, "Option 2: Run Locally"**.
|
||||
For the Cloud MCP Server, go directly to the [Configuration Guide](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended) to set up your Claude Desktop, Cursor, or other MCP client.
|
||||
**This guide is focused on local installation, the Local MCP Server**.
|
||||
|
||||
## Installation Methods
|
||||
|
||||
@@ -51,7 +51,7 @@ Choose one of the following installation methods:
|
||||
|
||||
```bash
|
||||
docker run --rm -i \
|
||||
-e PROWLER_APP_API_KEY="pk_your_api_key" \
|
||||
-e PROWLER_API_KEY="pk_your_api_key" \
|
||||
-e API_BASE_URL="https://api.prowler.com/api/v1" \
|
||||
prowlercloud/prowler-mcp
|
||||
```
|
||||
@@ -143,7 +143,7 @@ Choose one of the following installation methods:
|
||||
|
||||
## Updating Prowler MCP Server
|
||||
|
||||
When running Prowler MCP Server locally ("Option 2: Run Locally"), upgrade to the latest version using the same method chosen for installation. The hosted server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
|
||||
When running the Local MCP Server, upgrade to the latest version using the same method chosen for installation. The Cloud MCP Server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker">
|
||||
@@ -219,19 +219,19 @@ Configure the server using environment variables:
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
|----------|-------------|----------|---------|
|
||||
| `PROWLER_APP_API_KEY` | Prowler API key | Only for STDIO mode | - |
|
||||
| `PROWLER_API_KEY` | Prowler API key | Only for STDIO mode | - |
|
||||
| `API_BASE_URL` | Custom Prowler API endpoint | No | `https://api.prowler.com/api/v1` |
|
||||
| `PROWLER_MCP_TRANSPORT_MODE` | Default transport mode (overwritten by `--transport` argument) | No | `stdio` |
|
||||
|
||||
<CodeGroup>
|
||||
```bash macOS/Linux
|
||||
export PROWLER_APP_API_KEY="pk_your_api_key_here"
|
||||
export PROWLER_API_KEY="pk_your_api_key_here"
|
||||
export API_BASE_URL="https://api.prowler.com/api/v1"
|
||||
export PROWLER_MCP_TRANSPORT_MODE="http"
|
||||
```
|
||||
|
||||
```bash Windows PowerShell
|
||||
$env:PROWLER_APP_API_KEY="pk_your_api_key_here"
|
||||
$env:PROWLER_API_KEY="pk_your_api_key_here"
|
||||
$env:API_BASE_URL="https://api.prowler.com/api/v1"
|
||||
$env:PROWLER_MCP_TRANSPORT_MODE="http"
|
||||
```
|
||||
@@ -246,7 +246,7 @@ Never commit your API key to version control. Use environment variables or secur
|
||||
For convenience, create a `.env` file in the `mcp_server` directory:
|
||||
|
||||
```bash .env
|
||||
PROWLER_APP_API_KEY=pk_your_api_key_here
|
||||
PROWLER_API_KEY=pk_your_api_key_here
|
||||
API_BASE_URL=https://api.prowler.com/api/v1
|
||||
PROWLER_MCP_TRANSPORT_MODE=stdio
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@ Read the [public announcement of the Prowler product families](https://prowler-w
|
||||
| Prowler Private Cloud | Prowler Cloud deployed in your own environment. Formerly Prowler Enterprise. See [pricing](https://prowler.com/pricing). |
|
||||
| [Prowler Hub](https://hub.prowler.com) | Free public library of versioned checks, cloud service artifacts, and compliance frameworks. |
|
||||
| [Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse) | AI security analyst capabilities within Prowler Cloud and Prowler Private Cloud. |
|
||||
| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/getting-started/products/prowler-claude-code-plugin). |
|
||||
| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/user-guide/ai-agents/claude-code). |
|
||||
|
||||
{/* Unreleased products. Uncomment these rows in the Prowler Products table when announced:
|
||||
| Prowler Registry | Distribution service for Prowler content such as checks and compliance frameworks. Free and paid tiers. |
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
title: 'Prowler for Claude Code'
|
||||
sidebarTitle: 'Claude Code'
|
||||
---
|
||||
|
||||
End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
|
||||
|
||||
<Warning>
|
||||
**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback.
|
||||
</Warning>
|
||||
|
||||
## Requirements
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Claude Code" icon="terminal">
|
||||
Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code).
|
||||
</Card>
|
||||
<Card title="Prowler Cloud account" icon="cloud">
|
||||
The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
|
||||
</Card>
|
||||
<Card title="Prowler API key" icon="key">
|
||||
Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile).
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Installation
|
||||
|
||||
<Tabs>
|
||||
<Tab title="From GitHub (recommended)">
|
||||
Inside a Claude Code session:
|
||||
|
||||
```text
|
||||
/plugin marketplace add prowler-cloud/prowler
|
||||
/plugin install prowler@prowler-plugins
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="From a local clone">
|
||||
If you already have the repository checked out:
|
||||
|
||||
```text
|
||||
/plugin marketplace add /absolute/path/to/prowler
|
||||
/plugin install prowler@prowler-plugins
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
|
||||
On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
|
||||
|
||||
<Note>
|
||||
To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again.
|
||||
</Note>
|
||||
|
||||
## Verify the installation
|
||||
|
||||
In a Claude Code session:
|
||||
|
||||
```text
|
||||
/mcp → "prowler" appears as a connected server
|
||||
/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage
|
||||
```
|
||||
|
||||
If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
|
||||
|
||||
## Usage
|
||||
|
||||
Open a conversation that mentions the framework you want to comply with. Examples:
|
||||
|
||||
- *"Make my AWS production account compliant with CIS 4.0."*
|
||||
- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."*
|
||||
- *"Help me get to 100% on PCI-DSS for this GCP project."*
|
||||
|
||||
You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Claude-assisted (default)" icon="hand">
|
||||
Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
|
||||
</Card>
|
||||
<Card title="Claude autonomous" icon="robot">
|
||||
Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```text
|
||||
/plugin uninstall prowler@prowler-plugins
|
||||
/plugin marketplace remove prowler-plugins
|
||||
```
|
||||
|
||||
The stored API key is removed automatically.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. |
|
||||
| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. |
|
||||
| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
|
||||
@@ -24,6 +24,9 @@ The Agentic Cloud Defender does more than answer questions, it helps teams **fin
|
||||
<Card title="Normal and Agentic Views" icon="table-columns">
|
||||
Switch between the standard interface and a chat-first agentic view.
|
||||
</Card>
|
||||
<Card title="Side Panel on Every Page" icon="comment-dots">
|
||||
Open Lighthouse AI as a side panel from any page to get help in context.
|
||||
</Card>
|
||||
<Card title="Provider Connection Checks" icon="plug-circle-check">
|
||||
Credentials are validated automatically when a provider is configured.
|
||||
</Card>
|
||||
@@ -37,6 +40,20 @@ Promoting the chat to a top-level view gives Lighthouse AI the room it needs for
|
||||
|
||||
<img src="/images/prowler-app/lighthouse/prowler-cloud/main-chat-page.png" alt="Lighthouse AI chat view in Prowler Cloud" />
|
||||
|
||||
### Side Panel
|
||||
|
||||
You do not have to switch to the full chat view to reach Lighthouse AI. A side panel is available on every page of Prowler Cloud. While collapsed it stays out of the way; open it from any dashboard, findings list, or configuration screen to ask questions without leaving what you are working on. Open it using the Lighthouse AI button, circled in red in the image below.
|
||||
|
||||
<img src="/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png" alt="Collapsed Lighthouse AI side panel on a Prowler Cloud page, with the button to open it circled in red" />
|
||||
|
||||
Once open, the panel slides in alongside your current page and shares the same agent, tools, and persistent chat sessions as the full Chat View, so a conversation started in the panel can be reopened and continued later from either place.
|
||||
|
||||
<img src="/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png" alt="Lighthouse AI side panel open alongside a Prowler Cloud page" />
|
||||
|
||||
- **Available everywhere:** Summon the assistant from any page while you keep working in the normal view.
|
||||
- **Context-aware help:** Ask about the findings, resources, or compliance data you are currently looking at.
|
||||
- **Continuous sessions:** Conversations opened in the side panel are saved alongside the rest of your chat history.
|
||||
|
||||
### Tool Usage
|
||||
|
||||
Lighthouse AI on Prowler Cloud renders the agent's work as it happens, so responses are easier to follow and to trust. Tool calls and reasoning steps appear in the order they occur within the conversation.
|
||||
@@ -63,6 +80,53 @@ At the top of the configuration page, the optional **Business Context** field le
|
||||
|
||||
Lighthouse AI on Prowler Cloud supports OpenAI, Amazon Bedrock, and OpenAI-compatible providers, with GPT-5.5 as the default. For per-provider setup and how to switch the default provider or model, see [Using Multiple LLM Providers](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
|
||||
|
||||
## Capabilities
|
||||
|
||||
Lighthouse AI works through the [Prowler MCP Server](/getting-started/products/prowler-mcp), which gives the agent a growing catalog of tools to explore and act on your security data. These actions run inside Prowler and never modify your cloud resources. Everything the agent can do maps to one of the following capability areas.
|
||||
|
||||
### Findings and Finding Groups
|
||||
|
||||
- Search and filter security findings across every connected provider by severity, status, region, service, check, date range, and muted state.
|
||||
- Retrieve full finding details, including remediation guidance, check metadata, and affected resources.
|
||||
- Summarize findings with aggregate statistics and trends.
|
||||
- Browse finding groups aggregated by check and drill down into the specific resources each group affects.
|
||||
|
||||
### Resources
|
||||
|
||||
- List and filter cloud resources by provider, region, service, resource type, and tags.
|
||||
- Inspect a resource's configuration, metadata, and related findings.
|
||||
- Review the timeline of cloud API actions performed on a resource (AWS CloudTrail), including who did what and when.
|
||||
- Get an aggregate overview of the resources Prowler has discovered.
|
||||
|
||||
### Compliance
|
||||
|
||||
- Review high-level compliance status across all frameworks, with pass/fail statistics per framework.
|
||||
- Get a requirement-level breakdown for a specific framework, including failed requirements and their associated findings.
|
||||
|
||||
### Attack Paths
|
||||
|
||||
- List Attack Paths scans and discover the queries available for each completed scan.
|
||||
- Run graph-based queries to reveal privilege-escalation chains and exploitable misconfigurations.
|
||||
- Retrieve the Cartography graph schema to build accurate custom queries.
|
||||
|
||||
### Scans and Providers
|
||||
|
||||
- List, inspect, and rename security scans across providers.
|
||||
- Trigger manual scans and schedule automated daily scans for continuous monitoring.
|
||||
- Search connected providers and check their connection status, connect new providers, or remove existing ones.
|
||||
|
||||
### Muting
|
||||
|
||||
- Manage the mutelist for pattern-based bulk muting.
|
||||
- Create, update, list, and delete finding-specific mute rules, each with a documented reason and audit trail.
|
||||
|
||||
### Security Check Catalog and Documentation
|
||||
|
||||
- Browse and search the Prowler Hub catalog of security checks and compliance frameworks, including check code and automated fixers.
|
||||
- Search and retrieve official Prowler documentation to answer how-to and product questions.
|
||||
|
||||
For the complete list of underlying tools, see the [Prowler MCP Tools Reference](/getting-started/basic-usage/prowler-mcp-tools).
|
||||
|
||||
## FAQ
|
||||
|
||||
**Which LLM providers are supported?**
|
||||
@@ -71,7 +135,11 @@ OpenAI (GPT models, including the default GPT-5.5), Amazon Bedrock (Claude, Llam
|
||||
|
||||
**Can Lighthouse AI change my cloud environment?**
|
||||
|
||||
No. Lighthouse AI has read-only access to security data and no tools to modify resources, even when the connected cloud credentials would allow changes.
|
||||
No. Lighthouse AI cannot modify the resources in your connected cloud providers (AWS, Azure, GCP, and others). It has read-only access to that environment and no tools to change it, even when the connected cloud credentials would allow it.
|
||||
|
||||
**Can Lighthouse AI change my Prowler Cloud environment?**
|
||||
|
||||
Yes. Lighthouse AI can take action within Prowler Cloud itself, such as connecting or removing providers, triggering and scheduling scans, and managing mute rules and the mutelist. See [Capabilities](#capabilities) for the full list of what it can do. These actions only affect your Prowler Cloud workspace, never the resources in your cloud providers.
|
||||
|
||||
## Looking for the Open Source Version?
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: "Prowler for MSPs and MSSPs"
|
||||
sidebarTitle: "Overview"
|
||||
---
|
||||
|
||||
Prowler for MSPs and MSSPs is a dedicated console for Managed Service Providers (MSPs), Managed Security Service Providers (MSSPs), resellers and consultants who run cloud security for other organizations. It lets a provider onboard customers, group them, manage a team, and operate each customer's Prowler Cloud tenant on their behalf.
|
||||
|
||||
The console is available at [partners.prowler.com](https://partners.prowler.com).
|
||||
|
||||
<Card title="Sign Up for Prowler for MSPs and MSSPs" icon="rocket" href="https://partners.prowler.com/sign-up" />
|
||||
|
||||
## What You Get
|
||||
|
||||
* **Customer onboarding:** provision a Prowler Cloud tenant for each customer, with a billing plan selected up front.
|
||||
* **Delegated access:** open any customer's Prowler Cloud tenant from the console. Every action is attributed to you acting on behalf of that customer.
|
||||
* **Team and roles:** invite team members by email and assign a role that governs what they can do.
|
||||
* **Consolidated billing:** each customer carries its own plan, with month-to-date revenue reported across every customer.
|
||||
* **Branding:** upload your logo to appear alongside Prowler branding in the console.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Three objects make up the model. Getting these straight makes the rest of the documentation easy to follow.
|
||||
|
||||
| Object | What it is |
|
||||
|---|---|
|
||||
| **Partner organization** | The provider's own company. The top-level container for everything below, created at sign-up. |
|
||||
| **Customer** | One of the provider's customers. Each customer maps to a Prowler Cloud tenant and carries its own billing plan. |
|
||||
| **Team member** | A user in the partner organization, holding a role that governs what they can do. |
|
||||
|
||||
## How It Relates to Prowler Cloud
|
||||
|
||||
| | Prowler Cloud | Prowler for MSPs and MSSPs |
|
||||
|---|---|---|
|
||||
| **Audience** | End customers | MSPs, MSSPs, resellers, consultants |
|
||||
| **Console** | [cloud.prowler.com](https://cloud.prowler.com) | [partners.prowler.com](https://partners.prowler.com) |
|
||||
| **Scope** | One organization's own cloud accounts | Many customer organizations |
|
||||
| **Billing** | Each organization pays for itself | The provider manages a plan per customer |
|
||||
| **Branding** | Prowler-branded | Your logo alongside Prowler branding |
|
||||
|
||||
Your customers keep signing in to Prowler Cloud with their own users. Provider-side access is **additive** — it does not replace or restrict customer-side users.
|
||||
|
||||
## The Console at a Glance
|
||||
|
||||
Signing in lands you on the **Dashboard**. The sidebar carries:
|
||||
|
||||
| Entry | What it does | Visible to |
|
||||
|---|---|---|
|
||||
| **Dashboard** | Partner Insights, a Billing Overview card and an Active Customers table | Everyone |
|
||||
| **Customers** | Add customers, review their posture and billing, and open their Prowler Cloud tenant | Everyone |
|
||||
| **Team** | Invite, re-invite, disable and remove team members | Roles with **Manage members** |
|
||||
| **Settings** | Profile, Partner Code, branding and security | Everyone; editing requires **Manage settings** |
|
||||
|
||||

|
||||
|
||||
**Partner Insights** is the top row: **Total Customers**, broken down into active and non-paid; **Cloud Accounts**, broken down by cloud provider; and **Monitored Resources**, with a note on organizations whose critical risk has grown. Each card carries a 30-day trend.
|
||||
|
||||
Below it, **Billing Overview** reports monthly expenses against the previous month and splits revenue for the period into annual, monthly and overage. **Active Customers** lists your customers with their provider count, resource count and last completed scan, and carries its own **Add Customer** button.
|
||||
|
||||
## Getting Access
|
||||
|
||||
Sign-up is self-service, approval is not. Register at [partners.prowler.com/sign-up](https://partners.prowler.com/sign-up), then verify your email address — the organization sits in **Pending email verification** until you do, and the Prowler team does not review it before that. Verifying moves the organization to **Pending approval**. Once approved, you can invite your team and start onboarding customers.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Sign Up and Sign In" icon="user-plus" href="/user-guide/tutorials/prowler-for-msps-sign-up">
|
||||
Register, verify your email, and get approved.
|
||||
</Card>
|
||||
<Card title="Your Partner Organization" icon="briefcase" href="/user-guide/tutorials/prowler-for-msps-organization">
|
||||
Lifecycle, settings, Partner Code and closing your organization.
|
||||
</Card>
|
||||
<Card title="Onboarding Customers" icon="building" href="/user-guide/tutorials/prowler-for-msps-customers">
|
||||
Add customers and open their Prowler Cloud tenants.
|
||||
</Card>
|
||||
<Card title="Managing Your Team" icon="users" href="/user-guide/tutorials/prowler-for-msps-team">
|
||||
Invite team members and assign roles.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -8,8 +8,29 @@ title: "Overview"
|
||||
**Preview Feature**: This MCP server is currently under active development. Features and functionality may change. We welcome your feedback—please report any issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join our [Slack community](https://goto.prowler.com/slack) to discuss and share your thoughts.
|
||||
</Warning>
|
||||
|
||||
## Quickest Way to Connect: Cloud MCP Server
|
||||
|
||||
The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` — no installation, always up to date, and maintained by Prowler. Just point your MCP client at the URL and authenticate with a [Prowler API key](/user-guide/tutorials/prowler-app-api-keys) as a Bearer token:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"url": "https://mcp.prowler.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Card title="Connect Your MCP Client to the Cloud MCP Server" icon="cloud" href="/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended" horizontal>
|
||||
Step-by-step setup for Claude Code, Codex, Cursor, VS Code, and other agents.
|
||||
</Card>
|
||||
|
||||
<Note>
|
||||
Prowler MCP Server can run as a local instance, or as the hosted **Prowler MCP** at `https://mcp.prowler.com/mcp`. The hosted server also provides tools for Prowler Cloud-specific features such as [Alerts](/user-guide/tutorials/prowler-alerts), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage). See [Deployment Options](#deployment-options).
|
||||
Prefer to run it yourself? The **Local MCP Server** runs on your own machine or infrastructure. The Cloud MCP Server additionally provides tools for Prowler Cloud-specific features such as [Alerts](/user-guide/tutorials/prowler-alerts), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage). See [Cloud vs Local MCP Server](#cloud-vs-local-mcp-server).
|
||||
</Note>
|
||||
|
||||
## What is the Model Context Protocol?
|
||||
@@ -20,9 +41,9 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open s
|
||||
|
||||
The Prowler MCP Server provides three main integration points:
|
||||
|
||||
### 1. Prowler Cloud and Prowler Local Server
|
||||
### 1. Prowler Cloud, Private Cloud & Local Server
|
||||
|
||||
Full access to Prowler Cloud and Prowler Local Server for:
|
||||
Full access to your Prowler deployment — Prowler Cloud, Prowler Private Cloud, or Prowler Local Server — for:
|
||||
- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments
|
||||
- **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.)
|
||||
- **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments
|
||||
@@ -48,12 +69,53 @@ Search and retrieve official Prowler documentation:
|
||||
|
||||
## MCP Server Architecture
|
||||
|
||||
The following diagram illustrates the Prowler MCP Server architecture and its integration points:
|
||||
The following diagram illustrates the Prowler MCP Server architecture and its integration points. MCP clients connect to either the **Cloud MCP Server** (recommended) or a **Local MCP Server**; both expose the same tools and reach the same Prowler backends:
|
||||
|
||||

|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph HOSTS["MCP Clients"]
|
||||
chat["Chat Interfaces<br/>(Claude Desktop, LobeChat)"]
|
||||
ide["IDEs and Code Editors<br/>(Claude Code, Cursor)"]
|
||||
apps["Other AI Applications<br/>(5ire, custom agents)"]
|
||||
end
|
||||
|
||||
subgraph SERVERS["Prowler MCP Server"]
|
||||
direction TB
|
||||
cloud["☁️ Cloud MCP Server (Recommended)<br/>mcp.prowler.com/mcp · HTTP<br/>Managed by Prowler · always up to date<br/>Adds Cloud-only tools (Alerts,<br/>Scan Scheduling, Findings Triage)"]
|
||||
local["💻 Local MCP Server<br/>Self-run · STDIO or HTTP<br/>Python 3.12+ or Docker<br/>You manage updates"]
|
||||
end
|
||||
|
||||
subgraph TOOLS["Prowler MCP Tools"]
|
||||
prowler_tools["prowler_* tools<br/>(API key or JWT auth)<br/>Findings · Providers · Scans<br/>Resources · Muting · Compliance<br/>Attack Paths"]
|
||||
hub_tools["prowler_hub_* tools<br/>(no auth)<br/>Checks Catalog · Check Code<br/>Fixers · Compliance Frameworks"]
|
||||
docs_tools["prowler_docs_* tools<br/>(no auth)<br/>Search · Document Retrieval"]
|
||||
end
|
||||
|
||||
api["Prowler API (REST)<br/>Cloud · Private Cloud · Local Server"]
|
||||
hub["hub.prowler.com<br/>(REST)"]
|
||||
docs["docs.prowler.com<br/>(Mintlify)"]
|
||||
|
||||
chat -->|HTTP| cloud
|
||||
ide -->|HTTP| cloud
|
||||
apps -->|HTTP| cloud
|
||||
chat -->|STDIO or HTTP| local
|
||||
ide -->|STDIO or HTTP| local
|
||||
apps -->|STDIO or HTTP| local
|
||||
|
||||
cloud --> prowler_tools
|
||||
cloud --> hub_tools
|
||||
cloud --> docs_tools
|
||||
local --> prowler_tools
|
||||
local --> hub_tools
|
||||
local --> docs_tools
|
||||
|
||||
prowler_tools -->|REST| api
|
||||
hub_tools -->|REST| hub
|
||||
docs_tools -->|REST| docs
|
||||
```
|
||||
|
||||
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components:
|
||||
- Prowler Cloud and Prowler Local Server for security operations
|
||||
- Prowler Cloud, Prowler Private Cloud, or Prowler Local Server for security operations
|
||||
- Prowler Hub for security knowledge
|
||||
- Prowler Documentation for guidance and reference.
|
||||
|
||||
@@ -92,8 +154,8 @@ REQUIREMENTS:
|
||||
|
||||
DATA TO FETCH:
|
||||
Use these MCP tools in this order:
|
||||
1. Prowler app list providers - To get all available configured provider in the account
|
||||
2. Prowler app get latest findings - To get findings information, if there are so many you can use the filter_fields to get less information, or pagination to get in different batches
|
||||
1. Prowler list providers - To get all available configured provider in the account
|
||||
2. Prowler get latest findings - To get findings information, if there are so many you can use the filter_fields to get less information, or pagination to get in different batches
|
||||
3. For most critical findings you can get more context and remediation with Prowler Hub to get remediations for example
|
||||
|
||||
DESIGN REQUIREMENTS:
|
||||
@@ -130,66 +192,48 @@ Generate the complete HTML file and display it
|
||||
></iframe>
|
||||
|
||||
|
||||
## Deployment Options
|
||||
## Cloud vs Local MCP Server
|
||||
|
||||
Prowler MCP Server can be used in three ways:
|
||||
There are two ways to run the Prowler MCP Server. For almost everyone, the **Cloud MCP Server** is the right choice — it needs no installation and is maintained by Prowler. The **Local MCP Server** exists for users who need to run it on their own machine or infrastructure.
|
||||
|
||||
### 1. Prowler Cloud MCP Server
|
||||
| | ☁️ **Cloud MCP Server** (Recommended) | 💻 **Local MCP Server** |
|
||||
|---|---|---|
|
||||
| **Endpoint** | `https://mcp.prowler.com/mcp` | Runs on your machine or infrastructure |
|
||||
| **Setup** | Just configure your MCP client | Install via Docker, or source |
|
||||
| **Transport** | HTTP | STDIO (subprocess) or self-hosted HTTP |
|
||||
| **Maintenance** | Managed by Prowler, always up to date | You manage updates |
|
||||
| **Requirements** | None (just an MCP client) | Python 3.12+ or Docker |
|
||||
| **Cloud-only tools** | ✅ Alerts, Scan Scheduling, Findings Triage | ❌ Not available |
|
||||
| **Authentication** | API key or JWT token | API key/JWT (HTTP) or env vars (STDIO) |
|
||||
|
||||
**Use Prowler's managed MCP server at `https://mcp.prowler.com/mcp`**
|
||||
### ☁️ Cloud MCP Server (Recommended)
|
||||
|
||||
- No installation required.
|
||||
- Managed and maintained by Prowler team.
|
||||
- Authentication to Prowler Cloud or Prowler Local Server via API key or JWT token.
|
||||
- Includes tools for Prowler Cloud-specific features such as Alerts, Scan Scheduling, and Findings Triage.
|
||||
Prowler's managed MCP server at `https://mcp.prowler.com/mcp`. No installation, always up to date, and it includes tools for Prowler Cloud-specific features such as Alerts, Scan Scheduling, and Findings Triage. This is the path we recommend for nearly all users — go straight to the [Configuration guide](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended).
|
||||
|
||||
### 2. Local STDIO Mode
|
||||
### 💻 Local MCP Server
|
||||
|
||||
**Run the server locally on your machine**
|
||||
Run the server yourself when you need full control over the deployment. It connects to Prowler Cloud, Prowler Private Cloud, or Prowler Local Server and can run in two modes:
|
||||
|
||||
- Runs as a subprocess of the MCP client.
|
||||
- Possibility to connect to Prowler Local Server.
|
||||
- Authentication to Prowler Cloud or Prowler Local Server via environment variables.
|
||||
- Requires Python 3.12+ or Docker.
|
||||
- **STDIO mode** — the server runs as a subprocess of your MCP client. Authentication via environment variables.
|
||||
- **Self-hosted HTTP mode** — deploy your own remote HTTP server. Authentication via API key or JWT token.
|
||||
|
||||
### 3. Self-Hosted HTTP Mode
|
||||
|
||||
**Deploy your own remote MCP server**
|
||||
|
||||
- Full control over deployment.
|
||||
- Possibility to connect to Prowler Local Server.
|
||||
- Authentication to Prowler Local Server via API key or JWT token.
|
||||
- Requires Python 3.12+ or Docker.
|
||||
|
||||
## Requirements
|
||||
|
||||
Requirements vary based on deployment option:
|
||||
|
||||
**For Prowler Cloud MCP Server:**
|
||||
- Prowler Cloud account and API key (only for Prowler Cloud and Prowler Local Server features)
|
||||
|
||||
**For self-hosted STDIO/HTTP Mode:**
|
||||
- Python 3.12+ or Docker
|
||||
- Network access to:
|
||||
- `https://hub.prowler.com` (for Prowler Hub)
|
||||
- `https://docs.prowler.com` (for Prowler Documentation)
|
||||
- Prowler Cloud API or Prowler Local Server API (for Prowler Cloud and Prowler Local Server features)
|
||||
Both require Python 3.12+ or Docker, plus network access to `https://hub.prowler.com` (Prowler Hub), `https://docs.prowler.com` (Prowler Documentation), and the Prowler API or Prowler Local Server API (Prowler features). See the [Installation guide](/getting-started/installation/prowler-mcp) to get started.
|
||||
|
||||
<Note>
|
||||
**No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication in both deployment options. A Prowler API key is only required to access Prowler Cloud or Prowler Local Server features.
|
||||
**No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication on both the Cloud and Local MCP Server. A Prowler API key is only required to access Prowler features (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
|
||||
</Note>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Installation" icon="download" href="/getting-started/installation/prowler-mcp">
|
||||
Install the Prowler MCP Server using uv or Docker
|
||||
</Card>
|
||||
<Card title="Configuration" icon="gear" href="/getting-started/basic-usage/prowler-mcp">
|
||||
Configure your MCP client to connect to the server
|
||||
Connect your MCP client to the Cloud MCP Server
|
||||
</Card>
|
||||
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools">
|
||||
Explore all available tools and capabilities
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools" horizontal>
|
||||
Explore all available tools and capabilities
|
||||
<Card title="Local Installation" icon="download" href="/getting-started/installation/prowler-mcp" horizontal>
|
||||
Run the Local MCP Server yourself using Docker, source, or uvx
|
||||
</Card>
|
||||
|
||||
@@ -15,7 +15,7 @@ flowchart TB
|
||||
llm["LLM Provider<br/>(OpenAI / Bedrock / OpenAI-compatible)"]
|
||||
|
||||
subgraph MCP["Prowler MCP Server"]
|
||||
app_tools["prowler_app_* tools<br/>(auth required)"]
|
||||
app_tools["prowler_* tools<br/>(auth required)"]
|
||||
hub_tools["prowler_hub_* tools<br/>(no auth)"]
|
||||
docs_tools["prowler_docs_* tools<br/>(no auth)"]
|
||||
end
|
||||
@@ -29,7 +29,7 @@ flowchart TB
|
||||
agent <-->|LLM API| llm
|
||||
agent --> metatools
|
||||
metatools --> mcpclient
|
||||
mcpclient -->|MCP HTTP · Bearer token<br/>for prowler_app_* only| app_tools
|
||||
mcpclient -->|MCP HTTP · Bearer token<br/>for prowler_* only| app_tools
|
||||
mcpclient -->|MCP HTTP| hub_tools
|
||||
mcpclient -->|MCP HTTP| docs_tools
|
||||
app_tools -->|REST| api
|
||||
|
||||
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 432 KiB |
@@ -3,41 +3,37 @@
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="108%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.08"/>
|
||||
</filter>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#9aa0a6"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="550" y="40" text-anchor="middle" font-size="22" font-weight="700" fill="#4285F4">Onboarding Flow</text>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<rect x="30" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#4285F4" stroke-width="2.5" stroke-dasharray="8 4" filter="url(#shadow)"/>
|
||||
<rect x="30" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#4285F4" stroke-width="2.5" filter="url(#shadow)"/>
|
||||
<circle cx="140" cy="100" r="22" fill="#4285F4"/>
|
||||
<text x="140" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">1</text>
|
||||
<text x="140" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Create Management</text>
|
||||
<text x="140" y="165" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Account Role</text>
|
||||
<rect x="60" y="185" width="160" height="24" rx="12" fill="#E8F0FE"/>
|
||||
<text x="140" y="201" text-anchor="middle" font-size="11" font-weight="600" fill="#4285F4">Quick Create or Manual</text>
|
||||
<text x="140" y="232" text-anchor="middle" font-size="12" fill="#5f6368">Allows Prowler to</text>
|
||||
<text x="140" y="248" text-anchor="middle" font-size="12" fill="#5f6368">discover your org</text>
|
||||
<text x="140" y="264" text-anchor="middle" font-size="12" fill="#5f6368">structure</text>
|
||||
<text x="140" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Start the Wizard</text>
|
||||
<rect x="75" y="168" width="130" height="24" rx="12" fill="#E8F0FE"/>
|
||||
<text x="140" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#4285F4">In Prowler Cloud</text>
|
||||
<text x="140" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Enter your Org ID</text>
|
||||
<text x="140" y="232" text-anchor="middle" font-size="12" fill="#5f6368">and OU/root target</text>
|
||||
|
||||
<!-- Arrow 1→2 -->
|
||||
<path d="M260 180 L290 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#9aa0a6"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<rect x="300" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#7B61FF" stroke-width="2.5" filter="url(#shadow)"/>
|
||||
<circle cx="410" cy="100" r="22" fill="#7B61FF"/>
|
||||
<text x="410" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">2</text>
|
||||
<text x="410" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Deploy StackSet</text>
|
||||
<rect x="340" y="165" width="140" height="24" rx="12" fill="#F3F0FF"/>
|
||||
<text x="410" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#7B61FF">In AWS Console</text>
|
||||
<text x="410" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Creates ProwlerScan</text>
|
||||
<text x="410" y="228" text-anchor="middle" font-size="12" fill="#5f6368">role in every</text>
|
||||
<text x="410" y="244" text-anchor="middle" font-size="12" fill="#5f6368">member account</text>
|
||||
<text x="410" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Deploy the Roles</text>
|
||||
<rect x="350" y="168" width="120" height="24" rx="12" fill="#F3F0FF"/>
|
||||
<text x="410" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#7B61FF">Single CF Stack</text>
|
||||
<text x="410" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Management role +</text>
|
||||
<text x="410" y="232" text-anchor="middle" font-size="12" fill="#5f6368">StackSet to members</text>
|
||||
<text x="410" y="248" text-anchor="middle" font-size="12" fill="#5f6368">in one CF stack</text>
|
||||
|
||||
<!-- Arrow 2→3 -->
|
||||
<path d="M530 180 L560 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
|
||||
@@ -46,11 +42,11 @@
|
||||
<rect x="570" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#00BFA5" stroke-width="2.5" filter="url(#shadow)"/>
|
||||
<circle cx="680" cy="100" r="22" fill="#00BFA5"/>
|
||||
<text x="680" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">3</text>
|
||||
<text x="680" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Run the Wizard</text>
|
||||
<rect x="615" y="165" width="130" height="24" rx="12" fill="#E0F7F4"/>
|
||||
<text x="680" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#00BFA5">In Prowler Cloud</text>
|
||||
<text x="680" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Discovers accounts,</text>
|
||||
<text x="680" y="228" text-anchor="middle" font-size="12" fill="#5f6368">tests connections</text>
|
||||
<text x="680" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Discover & Connect</text>
|
||||
<rect x="615" y="168" width="130" height="24" rx="12" fill="#E0F7F4"/>
|
||||
<text x="680" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#00BFA5">In Prowler Cloud</text>
|
||||
<text x="680" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Discovers accounts,</text>
|
||||
<text x="680" y="232" text-anchor="middle" font-size="12" fill="#5f6368">tests connections</text>
|
||||
|
||||
<!-- Arrow 3→4 -->
|
||||
<path d="M800 180 L830 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
|
||||
@@ -59,13 +55,13 @@
|
||||
<rect x="840" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#F9AB00" stroke-width="2.5" filter="url(#shadow)"/>
|
||||
<circle cx="950" cy="100" r="22" fill="#F9AB00"/>
|
||||
<text x="950" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">4</text>
|
||||
<text x="950" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Launch Scans</text>
|
||||
<rect x="898" y="165" width="104" height="24" rx="12" fill="#FEF7E0"/>
|
||||
<text x="950" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#F9AB00">Automatic</text>
|
||||
<text x="950" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Scans run on all</text>
|
||||
<text x="950" y="228" text-anchor="middle" font-size="12" fill="#5f6368">connected accounts</text>
|
||||
<text x="950" y="244" text-anchor="middle" font-size="12" fill="#5f6368">on your schedule</text>
|
||||
<text x="950" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Launch Scans</text>
|
||||
<rect x="898" y="168" width="104" height="24" rx="12" fill="#FEF7E0"/>
|
||||
<text x="950" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#F9AB00">Automatic</text>
|
||||
<text x="950" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Scans run on all</text>
|
||||
<text x="950" y="232" text-anchor="middle" font-size="12" fill="#5f6368">connected accounts</text>
|
||||
<text x="950" y="248" text-anchor="middle" font-size="12" fill="#5f6368">on your schedule</text>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="550" y="340" text-anchor="middle" font-size="13" fill="#9aa0a6">Steps 1 and 2 are done once in AWS | Steps 3 and 4 are done in Prowler Cloud</text>
|
||||
<text x="550" y="340" text-anchor="middle" font-size="13" fill="#9aa0a6">Step 2 runs once in AWS | Steps 1, 3 and 4 are in Prowler Cloud</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -47,7 +47,7 @@
|
||||
|
||||
<!-- Deploy badge -->
|
||||
<rect x="115" y="400" width="270" height="28" rx="14" fill="#FFF3E0" stroke="#F9AB00" stroke-width="1.5"/>
|
||||
<text x="250" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#E65100">Deploy: Quick Create link or Manual</text>
|
||||
<text x="250" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#E65100">Deploy: single stack or standalone</text>
|
||||
|
||||
<!-- ===== Prowler Cloud connector ===== -->
|
||||
<rect x="490" y="195" width="120" height="36" rx="8" fill="#F5F5F5" stroke="#E0E0E0" stroke-width="1"/>
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
<!-- Deploy badge -->
|
||||
<rect x="735" y="400" width="230" height="28" rx="14" fill="#E8F5E9" stroke="#66BB6A" stroke-width="1.5"/>
|
||||
<text x="850" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#2E7D32">Deploy: via CloudFormation StackSet</text>
|
||||
<text x="850" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#2E7D32">Deploy: StackSet (single stack)</text>
|
||||
|
||||
<!-- Footer labels -->
|
||||
<text x="250" y="478" text-anchor="middle" font-size="14" font-weight="700" fill="#4285F4">Prowler discovers</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 662 KiB |
|
After Width: | Height: | Size: 878 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 51 KiB |