Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d96df300 | ||
|
|
5e60402469 | ||
|
|
74a760517e | ||
|
|
2db3bebd15 | ||
|
|
b56df840fc | ||
|
|
bd9fa88d2a | ||
|
|
9adc248c05 | ||
|
|
ea0c7eb271 | ||
|
|
88c666a0d2 | ||
|
|
7275b46707 | ||
|
|
60bc06271a | ||
|
|
8abd72e857 | ||
|
|
9185b043da | ||
|
|
3dd6b29477 | ||
|
|
6db407ed3c | ||
|
|
0b98a34687 | ||
|
|
fc0204a40d | ||
|
|
7a62926a09 | ||
|
|
7a6a35afec | ||
|
|
b7281a5221 | ||
|
|
5c4b0ba1fe | ||
|
|
f3b8ac1dbb | ||
|
|
8dac2a7ccf | ||
|
|
6192b8ac32 | ||
|
|
e49babb9e7 | ||
|
|
f8be9afa7c | ||
|
|
ecf7ec8e85 | ||
|
|
a57a507cee | ||
|
|
9b565586da | ||
|
|
17f726f816 | ||
|
|
3fd748994a | ||
|
|
976220dbf5 | ||
|
|
a77e56b5a0 | ||
|
|
34e4d25576 | ||
|
|
d4a33c0d1c | ||
|
|
4c3017e2ed | ||
|
|
03f2ab46c9 | ||
|
|
7f1cdb82ae | ||
|
|
1bb3fc9bda | ||
|
|
463e8309f7 | ||
|
|
59a7d30a3e | ||
|
|
2ae1062e76 |
No files matched your search
@@ -54,7 +54,7 @@ runs:
|
||||
trivy-db-${{ runner.os }}-
|
||||
|
||||
- name: Run Trivy vulnerability scan (JSON)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
format: 'json'
|
||||
@@ -67,7 +67,7 @@ runs:
|
||||
|
||||
- name: Run Trivy vulnerability scan (SARIF)
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
format: 'sarif'
|
||||
|
||||
@@ -51,7 +51,8 @@ STDERR="$(mktemp)"
|
||||
trap 'rm -f "${STDERR}"' EXIT
|
||||
|
||||
set +e
|
||||
OUTPUT="$(osv-scanner scan source "${SCAN_ARGS[@]}" --format=json "$@" 2>"${STDERR}")"
|
||||
# ${a[@]+...} guard: an empty array trips `set -u` on bash before 4.4.
|
||||
OUTPUT="$(osv-scanner scan source ${SCAN_ARGS[@]+"${SCAN_ARGS[@]}"} --format=json "$@" 2>"${STDERR}")"
|
||||
RC=$?
|
||||
set -e
|
||||
|
||||
@@ -100,6 +101,8 @@ FINDINGS="$(printf '%s' "${OUTPUT}" | jq --argjson sevs "${SEVERITY_JSON}" '
|
||||
]
|
||||
')"
|
||||
|
||||
# jq exits 0 with no output on empty stdin, but non-zero on malformed JSON.
|
||||
# Let the failure abort under set -e rather than reporting zero findings.
|
||||
COUNT="$(printf '%s' "${FINDINGS}" | jq 'length')"
|
||||
|
||||
# Write the findings JSON to OSV_REPORT_FILE so callers (e.g. the composite
|
||||
@@ -108,7 +111,7 @@ if [ -n "${OSV_REPORT_FILE:-}" ]; then
|
||||
printf '%s' "${FINDINGS}" > "${OSV_REPORT_FILE}"
|
||||
fi
|
||||
|
||||
if [ "${COUNT}" -gt 0 ]; then
|
||||
if [ "${COUNT:-0}" -gt 0 ]; then
|
||||
echo "osv-scanner: ${COUNT} finding(s) at severity ${SEVERITY_LEVELS}"
|
||||
printf '%s' "${FINDINGS}" | jq -r '
|
||||
.[] | " [\(.severity)\(if .score then " \(.score)" else "" end)] \(.id) \(.ecosystem)/\(.package)@\(.version) — \(.summary // "(no summary)")"
|
||||
|
||||
@@ -249,6 +249,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: api-serializers
|
||||
@@ -275,6 +276,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: api-filters
|
||||
@@ -432,6 +434,14 @@ modules:
|
||||
e2e:
|
||||
- ui/tests/lighthouse/**
|
||||
|
||||
- name: ui-navigation
|
||||
match:
|
||||
- ui/components/layout/**
|
||||
- ui/tests/navigation/**
|
||||
tests: []
|
||||
e2e:
|
||||
- ui/tests/navigation/**
|
||||
|
||||
- name: ui-overview
|
||||
match:
|
||||
- ui/components/overview/**
|
||||
@@ -464,6 +474,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: ui-attack-paths
|
||||
|
||||
@@ -105,7 +105,9 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: api/**
|
||||
files: |
|
||||
api/**
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
|
||||
@@ -107,6 +107,7 @@ jobs:
|
||||
files: |
|
||||
api/**
|
||||
.github/workflows/api-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
|
||||
@@ -98,7 +98,9 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: mcp_server/**
|
||||
files: |
|
||||
mcp_server/**
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
mcp_server/README.md
|
||||
mcp_server/CHANGELOG.md
|
||||
|
||||
@@ -25,6 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -33,6 +34,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Enable release freeze
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: 'Tools: Release Freeze Gate'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- checks_requested
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release-freeze-gate:
|
||||
name: release-freeze-gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check release freeze status
|
||||
env:
|
||||
RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }}
|
||||
run: |
|
||||
case "${RELEASE_FREEZE}" in
|
||||
true|TRUE|True)
|
||||
echo "::error::Release freeze is active. Merges to master are temporarily blocked."
|
||||
echo "Set the RELEASE_FREEZE repository variable to false when the release is complete."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Release freeze is not active."
|
||||
;;
|
||||
esac
|
||||
@@ -113,6 +113,7 @@ jobs:
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
.github/workflows/sdk-container-checks.yml
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
|
||||
@@ -99,7 +99,9 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: ui/**
|
||||
files: |
|
||||
ui/**
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
ui/CHANGELOG.md
|
||||
ui/changelog.d/**
|
||||
|
||||
@@ -36,6 +36,7 @@ jobs:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -118,6 +119,104 @@ jobs:
|
||||
env:
|
||||
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
|
||||
|
||||
- name: Validate E2E prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
declare -A required=()
|
||||
|
||||
suite_selected() {
|
||||
[[ "${RUN_ALL_TESTS}" == "true" ]] ||
|
||||
[[ " ${E2E_TEST_PATHS} " == *"ui/tests/$1/"* ]]
|
||||
}
|
||||
|
||||
require_vars() {
|
||||
local variable
|
||||
for variable in "$@"; do
|
||||
required["${variable}"]=1
|
||||
done
|
||||
}
|
||||
|
||||
if suite_selected auth || suite_selected providers ||
|
||||
suite_selected invitations || suite_selected scans ||
|
||||
suite_selected navigation; then
|
||||
require_vars E2E_ADMIN_USER E2E_ADMIN_PASSWORD
|
||||
fi
|
||||
|
||||
if suite_selected sign-up; then
|
||||
require_vars E2E_NEW_USER_PASSWORD
|
||||
fi
|
||||
|
||||
if suite_selected invitations; then
|
||||
require_vars E2E_NEW_USER_PASSWORD E2E_ORGANIZATION_ID
|
||||
fi
|
||||
|
||||
if suite_selected scans; then
|
||||
require_vars \
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID \
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY \
|
||||
E2E_AWS_PROVIDER_SECRET_KEY
|
||||
fi
|
||||
|
||||
if suite_selected providers; then
|
||||
require_vars \
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID \
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY \
|
||||
E2E_AWS_PROVIDER_SECRET_KEY \
|
||||
E2E_AWS_PROVIDER_ROLE_ARN \
|
||||
E2E_AZURE_SUBSCRIPTION_ID \
|
||||
E2E_AZURE_CLIENT_ID \
|
||||
E2E_AZURE_SECRET_ID \
|
||||
E2E_AZURE_TENANT_ID \
|
||||
E2E_M365_DOMAIN_ID \
|
||||
E2E_M365_CLIENT_ID \
|
||||
E2E_M365_SECRET_ID \
|
||||
E2E_M365_TENANT_ID \
|
||||
E2E_M365_CERTIFICATE_CONTENT \
|
||||
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY \
|
||||
E2E_GCP_PROJECT_ID \
|
||||
E2E_GITHUB_APP_ID \
|
||||
E2E_GITHUB_BASE64_APP_PRIVATE_KEY \
|
||||
E2E_GITHUB_USERNAME \
|
||||
E2E_GITHUB_PERSONAL_ACCESS_TOKEN \
|
||||
E2E_GITHUB_ORGANIZATION \
|
||||
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN \
|
||||
E2E_OCI_TENANCY_ID \
|
||||
E2E_OCI_USER_ID \
|
||||
E2E_OCI_FINGERPRINT \
|
||||
E2E_OCI_KEY_CONTENT \
|
||||
E2E_ALIBABACLOUD_ACCOUNT_ID \
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_ID \
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET \
|
||||
E2E_ALIBABACLOUD_ROLE_ARN \
|
||||
E2E_OKTA_DOMAIN \
|
||||
E2E_OKTA_CLIENT_ID \
|
||||
E2E_OKTA_BASE64_PRIVATE_KEY \
|
||||
E2E_GOOGLEWORKSPACE_CUSTOMER_ID \
|
||||
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON \
|
||||
E2E_GOOGLEWORKSPACE_DELEGATED_USER \
|
||||
E2E_VERCEL_TEAM_ID \
|
||||
E2E_VERCEL_API_TOKEN
|
||||
fi
|
||||
|
||||
missing=()
|
||||
if (( ${#required[@]} > 0 )); then
|
||||
while IFS= read -r variable; do
|
||||
[[ -z "${!variable:-}" ]] && missing+=("${variable}")
|
||||
done < <(printf '%s\n' "${!required[@]}" | sort)
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "Missing required E2E variables:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
{
|
||||
echo "## Missing E2E prerequisites"
|
||||
printf -- "- \`%s\`\n" "${missing[@]}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "E2E prerequisite preflight passed."
|
||||
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1
|
||||
with:
|
||||
@@ -304,6 +403,29 @@ jobs:
|
||||
run: |
|
||||
docker compose down -v || true
|
||||
|
||||
# Fork pull requests cannot access the secrets required by the E2E suites.
|
||||
fork-e2e-unavailable:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == true &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Report unavailable E2E tests
|
||||
run: |
|
||||
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "UI E2E tests require repository secrets and cannot run for fork pull requests." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Skip job - provides clear feedback when no E2E tests needed
|
||||
skip-e2e:
|
||||
needs: impact-analysis
|
||||
|
||||
@@ -181,9 +181,9 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Run browser tests
|
||||
- name: Run integration tests
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run test:browser
|
||||
run: pnpm run test:integration
|
||||
|
||||
- name: Build application
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -173,3 +173,5 @@ GEMINI.md
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose-dev.override.yml
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
@@ -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 @@
|
||||
Attack Paths adds four AWS privilege-escalation detection queries from pathfinding.cloud: cross-account role trust (STS-002), wildcard role trust (STS-003), user permissions-boundary removal (IAM-022), and IAM Identity Center permission-set escalation (SSO-001)
|
||||
@@ -0,0 +1 @@
|
||||
Attack Paths predefined queries on migrated graphs are now scoped with the provider label, letting the graph database seed from its label index instead of a global label scan and preventing query timeouts on Neptune
|
||||
@@ -0,0 +1 @@
|
||||
`task_args` serialization no longer returns HTTP 500 errors when Celery truncates stored task keyword arguments
|
||||
@@ -0,0 +1 @@
|
||||
Provider deletion and connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility
|
||||
@@ -0,0 +1 @@
|
||||
SAML users without a `userType` attribute and without an existing role in the SAML tenant now receive a least-privilege `read_only` fallback role; a numeric suffix is used when that name belongs to a role with different permissions
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Social signups create users and authentication records in one database transaction, preventing incomplete accounts when provisioning fails
|
||||
@@ -1,7 +1,7 @@
|
||||
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_router import MainRouter, write_db_alias
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
Membership,
|
||||
@@ -107,7 +107,10 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
and is about to be saved to the DB for the first time.
|
||||
"""
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
# Allauth saves the user without an explicit alias. Route that save
|
||||
# through admin so every signup record shares this transaction.
|
||||
with write_db_alias(MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
provider = sociallogin.provider.id
|
||||
extra = sociallogin.account.extra_data
|
||||
|
||||
|
||||
@@ -3536,6 +3536,160 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition(
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# STS-002
|
||||
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST = AttackPathsQueryDefinition(
|
||||
id="aws-sts-privesc-cross-account-trust",
|
||||
name="Cross-Account Role Trust for Privilege Escalation (STS-002)",
|
||||
short_description="Roles that trust an external account's root principal can be assumed by any principal in that account, enabling confused-deputy escalation.",
|
||||
description="Detect IAM roles whose trust policy allows an external AWS account root principal (arn:aws:iam::<account-id>:root) to assume them. Any principal in the trusted external account that holds sts:AssumeRole can assume the role and gain its permissions, which is the confused-deputy escalation surface. The ingested graph does not record trust-policy conditions, so roles protected by an sts:ExternalId condition cannot be filtered out automatically and are surfaced here for manual review.",
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - STS-002 - sts:AssumeRole",
|
||||
link="https://pathfinding.cloud/paths/sts-002",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find roles that trust an external account's root principal (cross-account trust)
|
||||
MATCH path_target = (aws:AWSAccount {{id: $provider_uid}})--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted:AWSRootPrincipal)
|
||||
WHERE trusted.arn CONTAINS ':root'
|
||||
AND NOT trusted.arn CONTAINS aws.id
|
||||
|
||||
WITH DISTINCT path_target
|
||||
WITH collect(path_target) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# STS-003
|
||||
AWS_STS_PRIVESC_WILDCARD_TRUST = AttackPathsQueryDefinition(
|
||||
id="aws-sts-privesc-wildcard-trust",
|
||||
name="Potential Wildcard Role Trust (STS-003)",
|
||||
short_description="Potential wildcard role trusts that need manual review before they are treated as assumable.",
|
||||
description='Find IAM roles linked to a wildcard principal ("AWS": "*"). The ingested graph does not preserve trust-policy Effect or Condition fields, so a match can come from a Deny statement or a restricted Allow statement. Treat each result as a candidate for manual review, not as a confirmed assumable role.',
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - STS-003 - sts:AssumeRole",
|
||||
link="https://pathfinding.cloud/paths/sts-003",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find roles linked to a wildcard principal for manual review
|
||||
MATCH path_target = (aws:AWSAccount {{id: $provider_uid}})--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted:AWSPrincipal)
|
||||
WHERE trusted.arn = '*'
|
||||
|
||||
WITH DISTINCT path_target
|
||||
WITH collect(path_target) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# IAM-022
|
||||
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY = AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-delete-user-permissions-boundary",
|
||||
name="Permissions Boundary Removal for Self-Escalation (IAM-022)",
|
||||
short_description="IAM users that can remove their own permissions boundary, if one is attached.",
|
||||
description="Find IAM users whose policies allow iam:DeleteUserPermissionsBoundary on their own user ARN. The graph does not record whether a boundary is attached or whether removing it grants more access, so each result needs manual review.",
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - IAM-022 - iam:DeleteUserPermissionsBoundary",
|
||||
link="https://pathfinding.cloud/paths/iam-022",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find IAM users with iam:DeleteUserPermissionsBoundary permission
|
||||
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
|
||||
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act.value) IN ['iam:*', 'iam:deleteuserpermissionsboundary']
|
||||
OR act.value = '*'
|
||||
WITH DISTINCT principal, stmt, path_principal
|
||||
|
||||
// Keep only users that can remove the boundary from their own user ARN
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value = principal.arn
|
||||
OR (res.value ENDS WITH '*' AND principal.arn STARTS WITH replace(res.value, '*', ''))
|
||||
|
||||
WITH DISTINCT path_principal
|
||||
WITH collect(path_principal) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# SSO-001
|
||||
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION = AttackPathsQueryDefinition(
|
||||
id="aws-sso-privesc-permission-set-escalation",
|
||||
name="Identity Center Permission Set Escalation (SSO-001)",
|
||||
short_description="Create an administrative Identity Center permission set and assign it to gain organization-wide admin access.",
|
||||
description="Detect principals that hold sso:CreatePermissionSet, sso:AttachManagedPolicyToPermissionSet, and sso:CreateAccountAssignment together. With all three, a principal can create a new IAM Identity Center permission set, attach the AdministratorAccess managed policy to it, and assign it to their own user or group for any account in the organization, gaining administrative access across the organization through the Identity Center portal.",
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - SSO-001 - sso:CreatePermissionSet + sso:AttachManagedPolicyToPermissionSet + sso:CreateAccountAssignment",
|
||||
link="https://pathfinding.cloud/paths/sso-001",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find principals with sso:CreatePermissionSet permission
|
||||
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
|
||||
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act.value) IN ['sso:*', 'sso:createpermissionset']
|
||||
OR act.value = '*'
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
WITH DISTINCT aws, principal, path_principal
|
||||
|
||||
// Find sso:AttachManagedPolicyToPermissionSet permission on the same principal
|
||||
MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act2.value) IN ['sso:*', 'sso:attachmanagedpolicytopermissionset']
|
||||
OR act2.value = '*'
|
||||
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
|
||||
WHERE res2.value = '*'
|
||||
WITH DISTINCT principal, path_principal
|
||||
|
||||
// Find sso:CreateAccountAssignment permission on the same principal
|
||||
MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt3:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act3.value) IN ['sso:*', 'sso:createaccountassignment']
|
||||
OR act3.value = '*'
|
||||
MATCH (stmt3)-[:HAS_RESOURCE]->(res3:AWSPolicyStatementResourceItem)
|
||||
WHERE res3.value = '*'
|
||||
|
||||
WITH DISTINCT path_principal
|
||||
WITH collect(path_principal) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# AWS Queries List
|
||||
|
||||
AWS_QUERIES: list[AttackPathsQueryDefinition] = [
|
||||
@@ -3617,4 +3771,8 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [
|
||||
AWS_SSM_PRIVESC_START_SESSION,
|
||||
AWS_SSM_PRIVESC_SEND_COMMAND,
|
||||
AWS_STS_PRIVESC_ASSUME_ROLE,
|
||||
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
|
||||
AWS_STS_PRIVESC_WILDCARD_TRUST,
|
||||
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
|
||||
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION,
|
||||
]
|
||||
@@ -115,7 +115,26 @@ def execute_query(
|
||||
# TODO: drop after Neptune cutover
|
||||
# Route reads by the scan row's recorded sink, not by current settings.
|
||||
backend = sink_module.get_backend_for_scan(scan)
|
||||
graph = backend.execute_read_query(database_name, definition.cypher, parameters)
|
||||
|
||||
cypher = definition.cypher
|
||||
# Every synced node carries a `_Provider_{uuid}` isolation label (the
|
||||
# sync labels the whole provider subgraph). Injecting it into the
|
||||
# predefined query's node patterns gives the planner a selective label
|
||||
# index to seed from instead of a global label scan (`:AWSRole` across
|
||||
# every tenant), which on Neptune is the difference between a sub-second
|
||||
# plan and a query that times out. The custom-query path relies on this
|
||||
# same injection.
|
||||
#
|
||||
# Restrict it to migrated scans: that catalog runs on the Neptune sink
|
||||
# where the plan blowup happens, while the pre-cutover legacy catalog
|
||||
# runs on the old sink and is dropped after the cutover, so leave it
|
||||
# byte-for-byte unchanged. This only affects the query plan, not
|
||||
# isolation - `_serialize_graph` already label-filters both catalogs.
|
||||
# TODO: drop the is_migrated guard after Neptune cutover
|
||||
if scan.is_migrated:
|
||||
cypher = inject_provider_label(cypher, provider_id)
|
||||
|
||||
graph = backend.execute_read_query(database_name, cypher, parameters)
|
||||
return _serialize_graph(graph, provider_id)
|
||||
|
||||
except graph_database.WriteQueryNotAllowedException:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import ast
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
_UNPARSED = object()
|
||||
|
||||
|
||||
def decode_celery_field(value: Any, default: Any) -> Any:
|
||||
"""Decode a Celery result field and require JSON-serializable output."""
|
||||
decoded = value
|
||||
for _ in range(2):
|
||||
if not isinstance(decoded, str):
|
||||
break
|
||||
|
||||
text = decoded.strip()
|
||||
if not text:
|
||||
decoded = default
|
||||
break
|
||||
|
||||
parsed = _UNPARSED
|
||||
for parser in (json.loads, ast.literal_eval):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
break
|
||||
except (TypeError, ValueError, SyntaxError):
|
||||
continue
|
||||
|
||||
if parsed is _UNPARSED:
|
||||
raise ValueError("Unable to decode Celery result field")
|
||||
decoded = parsed
|
||||
|
||||
decoded = default if decoded is None else decoded
|
||||
try:
|
||||
json.dumps(decoded, allow_nan=False)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(
|
||||
"Decoded Celery result field is not JSON serializable"
|
||||
) from error
|
||||
|
||||
return decoded
|
||||
@@ -1,3 +1,4 @@
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
from django.conf import settings
|
||||
@@ -5,6 +6,7 @@ from django.conf import settings
|
||||
ALLOWED_APPS = ("django", "socialaccount", "account", "authtoken", "silk")
|
||||
|
||||
_read_db_alias = ContextVar("read_db_alias", default=None)
|
||||
_write_db_alias = ContextVar("write_db_alias", default=None)
|
||||
|
||||
|
||||
def set_read_db_alias(alias: str | None):
|
||||
@@ -22,6 +24,30 @@ def reset_read_db_alias(token) -> None:
|
||||
_read_db_alias.reset(token)
|
||||
|
||||
|
||||
def set_write_db_alias(alias: str | None):
|
||||
if not alias:
|
||||
return None
|
||||
return _write_db_alias.set(alias)
|
||||
|
||||
|
||||
def get_write_db_alias() -> str | None:
|
||||
return _write_db_alias.get()
|
||||
|
||||
|
||||
def reset_write_db_alias(token) -> None:
|
||||
if token is not None:
|
||||
_write_db_alias.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def write_db_alias(alias: str | None):
|
||||
token = set_write_db_alias(alias)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_write_db_alias(token)
|
||||
|
||||
|
||||
class MainRouter:
|
||||
default_db = "default"
|
||||
admin_db = "admin"
|
||||
@@ -43,6 +69,9 @@ class MainRouter:
|
||||
model_table_name = model._meta.db_table
|
||||
if any(model_table_name.startswith(f"{app}_") for app in ALLOWED_APPS):
|
||||
return self.admin_db
|
||||
write_alias = get_write_db_alias()
|
||||
if write_alias:
|
||||
return write_alias
|
||||
return None
|
||||
|
||||
def allow_migrate(self, db, app_label, model_name=None, **hints): # noqa: F841
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,10 +10,12 @@ 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.db_router import MainRouter, get_write_db_alias
|
||||
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.db import connections
|
||||
from django.db import router as django_router
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -382,6 +384,65 @@ class TestProwlerSocialAccountAdapter:
|
||||
role=Membership.RoleChoices.MEMBER,
|
||||
).exists()
|
||||
|
||||
def test_save_user_routes_initial_allauth_write_to_admin_and_resets_on_error(
|
||||
self, rf
|
||||
):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.get("/")
|
||||
request.session = {}
|
||||
sociallogin = _oauth_sociallogin(
|
||||
User(name="Frank", email="frank-routing@example.com")
|
||||
)
|
||||
|
||||
def fail_after_checking_write_route(*_args, **_kwargs):
|
||||
assert (
|
||||
MainRouter().db_for_write(User, instance=sociallogin.user)
|
||||
== MainRouter.admin_db
|
||||
)
|
||||
raise RuntimeError("Stop after checking the write route.")
|
||||
|
||||
with (
|
||||
patch("api.adapters.super") as mock_super,
|
||||
patch("api.adapters.transaction.atomic"),
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
pytest.raises(RuntimeError, match="Stop after checking the write route"),
|
||||
):
|
||||
mock_super.return_value.save_user.side_effect = (
|
||||
fail_after_checking_write_route
|
||||
)
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_rolls_back_all_signup_records_on_downstream_error(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-rollback@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-rollback-google-account",
|
||||
)
|
||||
tenants_before = Tenant.objects.count()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.adapters.rls_transaction",
|
||||
side_effect=RuntimeError("Simulated downstream failure."),
|
||||
),
|
||||
pytest.raises(RuntimeError, match="Simulated downstream failure"),
|
||||
):
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert not User.objects.filter(email=email).exists()
|
||||
assert not SocialAccount.objects.filter(
|
||||
provider="google",
|
||||
uid="frank-rollback-google-account",
|
||||
).exists()
|
||||
assert not EmailAddress.objects.filter(email=email).exists()
|
||||
assert Tenant.objects.count() == tenants_before
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_saml_sets_session_flag(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.get("/")
|
||||
@@ -402,3 +463,104 @@ class TestProwlerSocialAccountAdapter:
|
||||
mock_super.return_value.save_user.return_value = mock_user
|
||||
adapter.save_user(request, sociallogin)
|
||||
assert request.session["saml_user_created"] == "123"
|
||||
|
||||
|
||||
@pytest.mark.requires_test_admin_alias
|
||||
@pytest.mark.django_db(transaction=True, databases=["default", "admin"])
|
||||
class TestProwlerSocialAccountAdapterMultiDatabase:
|
||||
@staticmethod
|
||||
def _production_router():
|
||||
return patch.object(django_router, "routers", [MainRouter()])
|
||||
|
||||
def test_save_user_rolls_back_across_production_database_aliases(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-multidb-rollback@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-multidb-rollback-google-account",
|
||||
)
|
||||
tenants_before = Tenant.objects.using("admin").count()
|
||||
|
||||
assert connections["default"] is not connections["admin"]
|
||||
assert (
|
||||
connections["default"].settings_dict["NAME"]
|
||||
== connections["admin"].settings_dict["NAME"]
|
||||
)
|
||||
|
||||
def fail_after_allauth_save(*_args, **_kwargs):
|
||||
assert sociallogin.user._state.db == MainRouter.admin_db
|
||||
assert connections["default"].get_autocommit()
|
||||
assert not connections["admin"].get_autocommit()
|
||||
raise RuntimeError("Simulated downstream failure.")
|
||||
|
||||
with (
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
self._production_router(),
|
||||
patch("api.adapters.rls_transaction", side_effect=fail_after_allauth_save),
|
||||
pytest.raises(RuntimeError, match="Simulated downstream failure"),
|
||||
):
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert connections["default"].get_autocommit()
|
||||
assert connections["admin"].get_autocommit()
|
||||
assert not User.objects.using("default").filter(email=email).exists()
|
||||
assert not User.objects.using("admin").filter(email=email).exists()
|
||||
assert (
|
||||
not SocialAccount.objects.using("admin")
|
||||
.filter(
|
||||
provider="google",
|
||||
uid="frank-multidb-rollback-google-account",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert not EmailAddress.objects.using("admin").filter(email=email).exists()
|
||||
assert Tenant.objects.using("admin").count() == tenants_before
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_commits_complete_signup_across_production_aliases(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-multidb-success@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-multidb-success-google-account",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
self._production_router(),
|
||||
):
|
||||
user = adapter.save_user(request, sociallogin)
|
||||
|
||||
user = User.objects.using("admin").get(id=user.id)
|
||||
assert user.email == email
|
||||
assert (
|
||||
SocialAccount.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
provider="google",
|
||||
uid="frank-multidb-success-google-account",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
EmailAddress.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
email=email,
|
||||
verified=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
Membership.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
role=Membership.RoleChoices.OWNER,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert get_write_db_alias() is None
|
||||
@@ -154,6 +154,88 @@ def test_execute_query_serializes_graph(
|
||||
assert result["relationships"][0]["label"] == "OWNS"
|
||||
|
||||
|
||||
def test_execute_query_injects_provider_label_when_migrated(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
):
|
||||
# On migrated graphs the predefined cypher must be scoped with the
|
||||
# provider label so the planner seeds from the label index instead of a
|
||||
# global label scan (the Neptune cartesian/timeout fix).
|
||||
definition = attack_paths_query_definition_factory(
|
||||
id="aws-iam",
|
||||
name="IAM",
|
||||
short_description="Short desc",
|
||||
description="",
|
||||
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
|
||||
parameters=[],
|
||||
)
|
||||
provider_id = "test-provider-123"
|
||||
plabel = get_provider_label(provider_id)
|
||||
parameters = {"provider_uid": "123"}
|
||||
|
||||
graph_result = MagicMock()
|
||||
graph_result.nodes = []
|
||||
graph_result.relationships = []
|
||||
sink_backend_stub.execute_read_query.return_value = graph_result
|
||||
|
||||
# Injection is gated on `is_migrated`, not the sink (it is a pure string
|
||||
# transform), so `neo4j` exercises the same code path as Neptune here.
|
||||
views_helpers.execute_query(
|
||||
"db-tenant-test",
|
||||
definition,
|
||||
parameters,
|
||||
provider_id=provider_id,
|
||||
scan=MagicMock(is_migrated=True, sink_backend="neo4j"),
|
||||
)
|
||||
|
||||
executed_cypher = sink_backend_stub.execute_read_query.call_args[0][1]
|
||||
assert executed_cypher != definition.cypher
|
||||
# Both node patterns are scoped - not just one. Asserting the exact rewrite
|
||||
# (rather than `f":{plabel}" in executed_cypher`, which a partial injection
|
||||
# would still satisfy) proves every node got the label and that injection
|
||||
# inserted labels and nothing else.
|
||||
assert executed_cypher == (
|
||||
f"MATCH (aws:AWSAccount:{plabel})--(target_role:AWSRole:{plabel}) "
|
||||
"RETURN target_role"
|
||||
)
|
||||
# Parameters are passed through untouched.
|
||||
assert sink_backend_stub.execute_read_query.call_args[0][2] == parameters
|
||||
|
||||
|
||||
def test_execute_query_does_not_inject_label_when_deprecated(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
):
|
||||
# The pre-cutover legacy catalog runs on the old sink and is removed after
|
||||
# the Neptune cutover, so it must run verbatim (no injection).
|
||||
definition = attack_paths_query_definition_factory(
|
||||
id="aws-iam",
|
||||
name="IAM",
|
||||
short_description="Short desc",
|
||||
description="",
|
||||
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
|
||||
parameters=[],
|
||||
)
|
||||
parameters = {"provider_uid": "123"}
|
||||
|
||||
graph_result = MagicMock()
|
||||
graph_result.nodes = []
|
||||
graph_result.relationships = []
|
||||
sink_backend_stub.execute_read_query.return_value = graph_result
|
||||
|
||||
views_helpers.execute_query(
|
||||
"db-tenant-test",
|
||||
definition,
|
||||
parameters,
|
||||
provider_id="test-provider-123",
|
||||
scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
|
||||
)
|
||||
|
||||
sink_backend_stub.execute_read_query.assert_called_once_with(
|
||||
"db-tenant-test", definition.cypher, parameters
|
||||
)
|
||||
|
||||
|
||||
def test_execute_query_wraps_graph_errors(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Structural validation tests for Attack Paths query definitions.
|
||||
|
||||
These tests verify that each query in the AWS_QUERIES registry meets the
|
||||
schema and convention requirements documented in
|
||||
`docs/developer-guide/attack-paths-queries.mdx` without requiring a live
|
||||
graph connection. They deliberately assert the conventions that keep queries
|
||||
functional and Neptune-compatible: list-typed policy properties are reached
|
||||
through `HAS_*` child-item traversals (never read as node fields), predicate
|
||||
functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are absent,
|
||||
the finding probe is typed and filters only on `status`, and the `RETURN`
|
||||
shape preserves the `paths, dpf, dpfr` contract.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.queries.aws import (
|
||||
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
|
||||
AWS_QUERIES,
|
||||
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION,
|
||||
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
|
||||
AWS_STS_PRIVESC_WILDCARD_TRUST,
|
||||
)
|
||||
from api.attack_paths.queries.types import AttackPathsQueryDefinition
|
||||
|
||||
# The pathfinding.cloud privilege-escalation queries added for PROWLER-2278.
|
||||
NEW_PATHFINDING_QUERIES = [
|
||||
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
|
||||
AWS_STS_PRIVESC_WILDCARD_TRUST,
|
||||
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
|
||||
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION,
|
||||
]
|
||||
|
||||
# Cypher keywords that indicate a mutating query (not allowed; queries are read-only).
|
||||
MUTATING_KEYWORDS = re.compile(
|
||||
r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", re.IGNORECASE
|
||||
)
|
||||
|
||||
# CALL subquery: unsupported by Neptune openCypher.
|
||||
CALL_SUBQUERY_PATTERN = re.compile(r"\bCALL\s*\{", re.IGNORECASE)
|
||||
|
||||
# Predicate functions that are not part of the openCypher spec and fail on Neptune.
|
||||
NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE)
|
||||
|
||||
# The list-typed policy properties that are exploded into child item nodes at sync
|
||||
# time and popped off the parent, so reading them as a field always yields null.
|
||||
NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource")
|
||||
|
||||
|
||||
class TestNewPathfindingQueriesRegistered:
|
||||
"""Every new query is present in the AWS_QUERIES registry."""
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_query_in_registry(self, query):
|
||||
assert query in AWS_QUERIES
|
||||
|
||||
|
||||
class TestNewPathfindingQueriesSchema:
|
||||
"""Required fields and naming conventions for each new query."""
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_is_query_definition_instance(self, query):
|
||||
assert isinstance(query, AttackPathsQueryDefinition)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_id_is_kebab_case(self, query):
|
||||
assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id), (
|
||||
f"Query id '{query.id}' is not kebab-case"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_id_starts_with_aws(self, query):
|
||||
assert query.id.startswith("aws-")
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_provider_is_aws(self, query):
|
||||
assert query.provider == "aws"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_name(self, query):
|
||||
assert query.name and len(query.name) > 5
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_short_description(self, query):
|
||||
assert query.short_description and len(query.short_description) > 10
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_description(self, query):
|
||||
assert query.description and len(query.description) > 20
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_attribution(self, query):
|
||||
assert query.attribution is not None
|
||||
assert "pathfinding.cloud" in query.attribution.text
|
||||
assert query.attribution.link.startswith("https://pathfinding.cloud/paths/")
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_parameters_is_list(self, query):
|
||||
assert isinstance(query.parameters, list)
|
||||
|
||||
|
||||
class TestNewPathfindingQueriesCypher:
|
||||
"""Cypher content, conventions, and Neptune compatibility."""
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_not_empty(self, query):
|
||||
assert query.cypher and len(query.cypher.strip()) > 0
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_under_10000_chars(self, query):
|
||||
assert len(query.cypher) < 10000, (
|
||||
f"Query {query.id} exceeds 10,000 character limit "
|
||||
f"({len(query.cypher)} chars)"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_uses_provider_uid_parameter(self, query):
|
||||
assert "$provider_uid" in query.cypher, (
|
||||
f"Query {query.id} missing $provider_uid parameter"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_finding_label_interpolated(self, query):
|
||||
# The f-string should have interpolated PROWLER_FINDING_LABEL already.
|
||||
assert "PROWLER_FINDING_LABEL" not in query.cypher, (
|
||||
f"Query {query.id} has unresolved PROWLER_FINDING_LABEL "
|
||||
"(f-string not applied)"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_finding_probe_is_typed_and_status_scoped(self, query):
|
||||
# The finding probe must be typed HAS_FINDING (so Neptune applies an inline
|
||||
# edge filter) and gate on FAIL status only. ProwlerFinding nodes carry no
|
||||
# provider_uid property, so a probe that filters on it never matches.
|
||||
assert re.search(
|
||||
r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)",
|
||||
query.cypher,
|
||||
), f"Query {query.id} does not use the typed, status-scoped finding probe"
|
||||
assert "provider_uid:$provider_uid}" not in query.cypher.replace(" ", ""), (
|
||||
f"Query {query.id} filters the finding node on a non-existent "
|
||||
"provider_uid property"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_is_read_only(self, query):
|
||||
cypher_no_comments = _strip_comment_lines(query.cypher)
|
||||
match = MUTATING_KEYWORDS.search(cypher_no_comments)
|
||||
assert match is None, (
|
||||
f"Query {query.id} contains mutating keyword: '{match.group()}'"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_no_call_subquery(self, query):
|
||||
assert not CALL_SUBQUERY_PATTERN.search(query.cypher), (
|
||||
f"Query {query.id} uses a CALL subquery (not Neptune-compatible)"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_no_neptune_unsupported_predicates(self, query):
|
||||
match = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher)
|
||||
assert match is None, (
|
||||
f"Query {query.id} uses '{match.group().strip()}' predicate function; "
|
||||
"use size([x IN list WHERE pred]) > 0 for Neptune compatibility"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_no_regex_operator(self, query):
|
||||
assert "=~" not in query.cypher, (
|
||||
f"Query {query.id} uses the regex operator '=~'; "
|
||||
"use CONTAINS / STARTS WITH for Neptune compatibility"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_does_not_read_normalized_list_fields(self, query):
|
||||
# action/resource/notaction/notresource are materialized as child item nodes
|
||||
# and popped off AWSPolicyStatement, so `stmt.action` etc. are always null.
|
||||
for field in NORMALIZED_STATEMENT_FIELDS:
|
||||
assert not re.search(rf"\.{field}\b", query.cypher), (
|
||||
f"Query {query.id} reads the normalized list field "
|
||||
f"'.{field}' as a node property; traverse the HAS_"
|
||||
f"{field.upper()} edge to the child item node instead"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_preserves_return_contract(self, query):
|
||||
assert re.search(
|
||||
r"RETURN paths, collect\(DISTINCT pf\) as dpf, "
|
||||
r"collect\(DISTINCT pfr\) as dpfr",
|
||||
query.cypher,
|
||||
), f"Query {query.id} does not preserve the 'paths, dpf, dpfr' RETURN contract"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_anchored_on_account(self, query):
|
||||
assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher, (
|
||||
f"Query {query.id} is not anchored on the AWSAccount node"
|
||||
)
|
||||
|
||||
|
||||
class TestNewPathfindingQueriesAccuracy:
|
||||
"""Query-specific contracts that prevent known false positives."""
|
||||
|
||||
def test_wildcard_trust_is_presented_as_a_manual_review_candidate(self):
|
||||
query = AWS_STS_PRIVESC_WILDCARD_TRUST
|
||||
text = f"{query.name} {query.short_description} {query.description}".lower()
|
||||
assert all(
|
||||
word in text
|
||||
for word in ("potential", "effect", "condition", "manual review")
|
||||
)
|
||||
|
||||
def test_permissions_boundary_removal_is_scoped_to_the_same_user(self):
|
||||
query = AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY
|
||||
assert "(principal:AWSUser)" in query.cypher
|
||||
assert (
|
||||
"(stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)"
|
||||
in query.cypher
|
||||
)
|
||||
assert "principal.arn" in query.cypher
|
||||
assert "manual review" in query.description.lower()
|
||||
|
||||
def test_permission_set_escalation_requires_global_resources(self):
|
||||
query = AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION
|
||||
for suffix in ("", "2", "3"):
|
||||
resource_match = (
|
||||
f"(stmt{suffix})-[:HAS_RESOURCE]->"
|
||||
f"(res{suffix}:AWSPolicyStatementResourceItem)"
|
||||
)
|
||||
assert resource_match in query.cypher
|
||||
assert f"WHERE res{suffix}.value = '*'" in query.cypher
|
||||
|
||||
|
||||
class TestAllQueriesUniqueIds:
|
||||
"""No duplicate IDs in the full registry."""
|
||||
|
||||
def test_no_duplicate_ids_in_aws_queries(self):
|
||||
ids = [q.id for q in AWS_QUERIES]
|
||||
duplicates = sorted({qid for qid in ids if ids.count(qid) > 1})
|
||||
assert not duplicates, f"Duplicate query IDs found: {duplicates}"
|
||||
|
||||
|
||||
def _strip_comment_lines(cypher: str) -> str:
|
||||
"""Drop `//` comment lines so keyword scans ignore prose in comments."""
|
||||
return "\n".join(
|
||||
line for line in cypher.split("\n") if not line.strip().startswith("//")
|
||||
)
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for the Cypher sanitizer (validation + provider-label injection)."""
|
||||
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -22,6 +23,38 @@ def _inject(cypher: str) -> str:
|
||||
return inject_provider_label(cypher, PROVIDER_ID)
|
||||
|
||||
|
||||
# String literals and line comments can contain parentheses that look like node
|
||||
# patterns; strip them first. Implemented here independently of the sanitizer so
|
||||
# the node count is an oracle for the injector rather than a copy of its regexes.
|
||||
_STRING_OR_COMMENT_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
|
||||
|
||||
# A node pattern is `(`, not preceded by a word char (which would make it a
|
||||
# function call), wrapping an optional variable, zero or more `:Label`s and an
|
||||
# optional `{property map}` - and nothing else, which excludes parenthesized
|
||||
# expressions such as `(a OR b)` in a WHERE clause.
|
||||
_NODE_PATTERN_RE = re.compile(
|
||||
r"(?<![\w`])\("
|
||||
r"\s*(?:[a-zA-Z_]\w*)?"
|
||||
r"(?:\s*:\s*(?:`[^`]*`|[a-zA-Z_]\w*))*"
|
||||
r"(?:\s*\{[^{}]*\})?"
|
||||
r"\s*\)"
|
||||
)
|
||||
|
||||
|
||||
def _count_node_patterns(cypher: str) -> int:
|
||||
"""Count node patterns in a query, independently of the injector.
|
||||
|
||||
Injection appends exactly one provider label per node pattern, so the
|
||||
number of injected labels must equal this count - proving *every* node is
|
||||
scoped, not just one."""
|
||||
stripped = _STRING_OR_COMMENT_RE.sub("", cypher)
|
||||
return sum(
|
||||
1
|
||||
for match in _NODE_PATTERN_RE.finditer(stripped)
|
||||
if match.group(0)[1:-1].strip()
|
||||
)
|
||||
|
||||
|
||||
def test_generic_inject_label_reuses_provider_injection_pipeline():
|
||||
result = inject_label("MATCH (n:AWSRole)--(m) RETURN n, m", "_Tenant_test")
|
||||
|
||||
@@ -427,3 +460,66 @@ class TestValidation:
|
||||
)
|
||||
def test_allows_clean_queries(self, cypher):
|
||||
validate_custom_query(cypher)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Predefined-catalog injection (Option 1: label-scoped predefined queries)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _all_predefined_queries():
|
||||
"""Every predefined query in the migrated catalog, as (id, cypher)."""
|
||||
from api.attack_paths.queries.registry import _QUERY_DEFINITIONS
|
||||
|
||||
return [
|
||||
(definition.id, definition.cypher)
|
||||
for definitions in _QUERY_DEFINITIONS.values()
|
||||
for definition in definitions
|
||||
]
|
||||
|
||||
|
||||
_PREDEFINED_QUERIES = _all_predefined_queries()
|
||||
|
||||
|
||||
class TestPredefinedCatalogInjection:
|
||||
"""`execute_query` injects the provider label into predefined queries on
|
||||
migrated graphs. The injection must be *lossless* for every catalog query:
|
||||
it may only insert `:_Provider_{uuid}` tokens and must not otherwise alter
|
||||
the cypher (which would corrupt a hand-authored query). This runs over the
|
||||
whole catalog so a regex regression is caught for all queries at once.
|
||||
|
||||
Injection is a pure string transform, so it is sink-independent (the same
|
||||
result is sent to Neo4j and Neptune)."""
|
||||
|
||||
def test_catalog_is_not_empty(self):
|
||||
# Guard against the parametrized tests silently covering nothing.
|
||||
assert len(_PREDEFINED_QUERIES) > 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cypher",
|
||||
[cypher for _, cypher in _PREDEFINED_QUERIES],
|
||||
ids=[query_id for query_id, _ in _PREDEFINED_QUERIES],
|
||||
)
|
||||
def test_injection_is_lossless(self, cypher):
|
||||
injected = _inject(cypher)
|
||||
|
||||
# Every node pattern is scoped - not just one. A partial-injection
|
||||
# regression that missed some nodes would still satisfy a bare
|
||||
# `f":{LABEL}" in injected` check, so assert the label count matches the
|
||||
# number of node patterns.
|
||||
assert injected.count(f":{LABEL}") == _count_node_patterns(cypher)
|
||||
# Stripping the injected tokens restores the query verbatim, proving
|
||||
# injection changed nothing but the labels.
|
||||
assert injected.replace(f":{LABEL}", "") == cypher
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cypher",
|
||||
[cypher for _, cypher in _PREDEFINED_QUERIES],
|
||||
ids=[query_id for query_id, _ in _PREDEFINED_QUERIES],
|
||||
)
|
||||
def test_injection_preserves_parameter_placeholders(self, cypher):
|
||||
# Label injection must never touch `$param` bindings.
|
||||
original_params = sorted(set(re.findall(r"\$\w+", cypher)))
|
||||
injected_params = sorted(set(re.findall(r"\$\w+", _inject(cypher))))
|
||||
|
||||
assert injected_params == original_params
|
||||
@@ -1,7 +1,13 @@
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from api.db_router import MainRouter
|
||||
from api.db_router import (
|
||||
MainRouter,
|
||||
get_write_db_alias,
|
||||
reset_write_db_alias,
|
||||
set_write_db_alias,
|
||||
write_db_alias,
|
||||
)
|
||||
from api.rls import Tenant
|
||||
from config.django.base import DATABASE_ROUTERS as PROD_DATABASE_ROUTERS
|
||||
from django.conf import settings
|
||||
@@ -26,6 +32,66 @@ class TestMainDatabaseRouter:
|
||||
assert router.allow_migrate_model(MainRouter.admin_db, api_model)
|
||||
assert not router.allow_migrate_model("default", api_model)
|
||||
|
||||
def test_scoped_write_alias_routes_api_models(self, router):
|
||||
token = set_write_db_alias(MainRouter.admin_db)
|
||||
try:
|
||||
assert get_write_db_alias() == MainRouter.admin_db
|
||||
assert router.db_for_write(Tenant) == MainRouter.admin_db
|
||||
finally:
|
||||
reset_write_db_alias(token)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
assert router.db_for_write(Tenant) == "default"
|
||||
|
||||
def test_scoped_write_alias_restores_nested_context(self, router):
|
||||
outer_token = set_write_db_alias("outer")
|
||||
try:
|
||||
assert router.db_for_write(Tenant) == "outer"
|
||||
|
||||
inner_token = set_write_db_alias(MainRouter.admin_db)
|
||||
try:
|
||||
assert router.db_for_write(Tenant) == MainRouter.admin_db
|
||||
finally:
|
||||
reset_write_db_alias(inner_token)
|
||||
|
||||
assert router.db_for_write(Tenant) == "outer"
|
||||
finally:
|
||||
reset_write_db_alias(outer_token)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
assert router.db_for_write(Tenant) == "default"
|
||||
|
||||
def test_scoped_write_alias_does_not_override_admin_models(self, router):
|
||||
token = set_write_db_alias("other")
|
||||
try:
|
||||
assert (
|
||||
router.db_for_write(MigrationRecorder.Migration) == MainRouter.admin_db
|
||||
)
|
||||
finally:
|
||||
reset_write_db_alias(token)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_write_db_alias_context_manager_resets_after_error(self, router):
|
||||
fail = Mock(side_effect=RuntimeError("Simulated failure"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="Simulated failure"):
|
||||
with write_db_alias(MainRouter.admin_db):
|
||||
assert get_write_db_alias() == MainRouter.admin_db
|
||||
assert router.db_for_write(Tenant) == MainRouter.admin_db
|
||||
fail()
|
||||
|
||||
fail.assert_called_once_with()
|
||||
assert get_write_db_alias() is None
|
||||
assert router.db_for_write(Tenant) == "default"
|
||||
|
||||
def test_write_db_alias_context_manager_ignores_empty_alias(self, router):
|
||||
with write_db_alias(None):
|
||||
assert get_write_db_alias() is None
|
||||
assert router.db_for_write(Tenant) == "default"
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_router_django_models(self, router):
|
||||
assert router.db_for_read(MigrationRecorder.Migration) == MainRouter.admin_db
|
||||
assert not router.db_for_read(MigrationRecorder.Migration) == "default"
|
||||
@@ -8,8 +8,10 @@ from api.models import (
|
||||
Membership,
|
||||
ProviderGroup,
|
||||
ProviderGroupMembership,
|
||||
ProviderSecret,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
Scan,
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
@@ -666,6 +668,612 @@ class TestLimitedVisibility:
|
||||
limited_admin_user, tenants_fixture[0]
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def hidden_provider_secret(self, aws_provider_pair):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
return ProviderSecret.objects.create(
|
||||
tenant_id=hidden_provider.tenant_id,
|
||||
provider=hidden_provider,
|
||||
secret_type=ProviderSecret.TypeChoices.STATIC,
|
||||
secret={
|
||||
"aws_access_key_id": "hidden-key",
|
||||
"aws_secret_access_key": "hidden-secret",
|
||||
},
|
||||
name="Hidden provider secret",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def limited_provider_group(self, limited_admin_user):
|
||||
return ProviderGroup.objects.get(name="limited_visibility_group")
|
||||
|
||||
@patch("api.v1.views.enqueue_scan_execution_on_commit")
|
||||
def test_scan_create_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
mock_enqueue_scan,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("scan-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {"name": "Out of scope scan"},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(hidden_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not Scan.objects.filter(
|
||||
provider=hidden_provider, name="Out of scope scan"
|
||||
).exists()
|
||||
mock_enqueue_scan.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.enqueue_scan_execution_on_commit")
|
||||
def test_scan_create_in_scope_provider_is_accepted(
|
||||
self,
|
||||
mock_enqueue_scan,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("scan-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {"name": "In scope scan"},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(aws_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert Scan.objects.filter(provider=aws_provider, name="In scope scan").exists()
|
||||
mock_enqueue_scan.assert_called_once()
|
||||
|
||||
def test_provider_secret_retrieve_out_of_scope_returns_404(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
hidden_provider_secret,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse(
|
||||
"providersecret-detail",
|
||||
kwargs={"pk": hidden_provider_secret.id},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_provider_secret_list_excludes_out_of_scope_provider(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
hidden_provider_secret,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(reverse("providersecret-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(hidden_provider_secret.id) not in {
|
||||
item["id"] for item in response.json()["data"]
|
||||
}
|
||||
|
||||
def test_provider_secret_create_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "Out of scope secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"aws_access_key_id": "hidden-key",
|
||||
"aws_secret_access_key": "hidden-secret",
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(hidden_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not ProviderSecret.objects.filter(provider=hidden_provider).exists()
|
||||
|
||||
def test_provider_secret_create_in_scope_provider_is_accepted(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "In scope secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"aws_access_key_id": "visible-key",
|
||||
"aws_secret_access_key": "visible-secret",
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(aws_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert ProviderSecret.objects.filter(provider=aws_provider).exists()
|
||||
|
||||
def test_provider_secret_update_out_of_scope_returns_404(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
hidden_provider_secret,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse(
|
||||
"providersecret-detail",
|
||||
kwargs={"pk": hidden_provider_secret.id},
|
||||
),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(hidden_provider_secret.id),
|
||||
"attributes": {"name": "Updated hidden secret"},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
hidden_provider_secret.refresh_from_db()
|
||||
assert hidden_provider_secret.name == "Hidden provider secret"
|
||||
|
||||
def test_provider_secret_delete_out_of_scope_returns_404(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
hidden_provider_secret,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse(
|
||||
"providersecret-detail",
|
||||
kwargs={"pk": hidden_provider_secret.id},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert ProviderSecret.objects.filter(id=hidden_provider_secret.id).exists()
|
||||
|
||||
def test_provider_group_create_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("providergroup-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-groups",
|
||||
"attributes": {"name": "Out of scope group"},
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{
|
||||
"type": "providers",
|
||||
"id": str(hidden_provider.id),
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not ProviderGroup.objects.filter(name="Out of scope group").exists()
|
||||
|
||||
def test_provider_group_create_in_scope_provider_is_accepted(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("providergroup-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-groups",
|
||||
"attributes": {"name": "In scope group"},
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{
|
||||
"type": "providers",
|
||||
"id": str(aws_provider.id),
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_group = ProviderGroup.objects.get(name="In scope group")
|
||||
assert set(provider_group.providers.all()) == {aws_provider}
|
||||
|
||||
def test_provider_group_update_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
limited_provider_group,
|
||||
aws_provider_pair,
|
||||
):
|
||||
visible_provider, hidden_provider = aws_provider_pair
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse(
|
||||
"providergroup-detail",
|
||||
kwargs={"pk": limited_provider_group.id},
|
||||
),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-groups",
|
||||
"id": str(limited_provider_group.id),
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{
|
||||
"type": "providers",
|
||||
"id": str(hidden_provider.id),
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert set(limited_provider_group.providers.all()) == {visible_provider}
|
||||
|
||||
def test_provider_group_relationship_create_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
limited_provider_group,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"provider_group-providers-relationship",
|
||||
kwargs={"pk": limited_provider_group.id},
|
||||
),
|
||||
data={
|
||||
"data": [
|
||||
{"type": "providers", "id": str(hidden_provider.id)},
|
||||
]
|
||||
},
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not ProviderGroupMembership.objects.filter(
|
||||
provider_group=limited_provider_group,
|
||||
provider=hidden_provider,
|
||||
).exists()
|
||||
|
||||
def test_provider_group_relationship_update_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
limited_provider_group,
|
||||
aws_provider_pair,
|
||||
):
|
||||
visible_provider, hidden_provider = aws_provider_pair
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse(
|
||||
"provider_group-providers-relationship",
|
||||
kwargs={"pk": limited_provider_group.id},
|
||||
),
|
||||
data={
|
||||
"data": [
|
||||
{"type": "providers", "id": str(hidden_provider.id)},
|
||||
]
|
||||
},
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert set(limited_provider_group.providers.all()) == {visible_provider}
|
||||
|
||||
def test_provider_group_relationship_create_in_scope_provider_is_accepted(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
limited_provider_group,
|
||||
aws_provider_pair,
|
||||
):
|
||||
additional_provider = aws_provider_pair[1]
|
||||
additional_group = ProviderGroup.objects.create(
|
||||
tenant_id=additional_provider.tenant_id,
|
||||
name="Additional visible group",
|
||||
)
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=additional_provider.tenant_id,
|
||||
provider_group=additional_group,
|
||||
provider=additional_provider,
|
||||
)
|
||||
RoleProviderGroupRelationship.objects.create(
|
||||
tenant_id=additional_provider.tenant_id,
|
||||
role=limited_provider_group.roles.get(),
|
||||
provider_group=additional_group,
|
||||
)
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"provider_group-providers-relationship",
|
||||
kwargs={"pk": limited_provider_group.id},
|
||||
),
|
||||
data={
|
||||
"data": [
|
||||
{"type": "providers", "id": str(additional_provider.id)},
|
||||
]
|
||||
},
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert ProviderGroupMembership.objects.filter(
|
||||
provider_group=limited_provider_group,
|
||||
provider=additional_provider,
|
||||
).exists()
|
||||
|
||||
def test_provider_group_relationship_delete_out_of_scope_group_returns_404(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
hidden_group = ProviderGroup.objects.create(
|
||||
tenant_id=hidden_provider.tenant_id,
|
||||
name="Unassigned provider group",
|
||||
)
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=hidden_provider.tenant_id,
|
||||
provider_group=hidden_group,
|
||||
provider=hidden_provider,
|
||||
)
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse(
|
||||
"provider_group-providers-relationship",
|
||||
kwargs={"pk": hidden_group.id},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert ProviderGroupMembership.objects.filter(
|
||||
provider_group=hidden_group,
|
||||
provider=hidden_provider,
|
||||
).exists()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.delete_provider_task.delay")
|
||||
def test_provider_delete_out_of_scope_returns_404(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
tasks_fixture,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_delete_task.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("provider-detail", kwargs={"pk": hidden_provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
hidden_provider.refresh_from_db()
|
||||
assert hidden_provider.is_deleted is False
|
||||
mock_delete_task.assert_not_called()
|
||||
mock_task_get.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.delete_provider_task.delay")
|
||||
def test_provider_delete_in_scope_returns_202(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_delete_task.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("provider-detail", kwargs={"pk": aws_provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_delete_task.assert_called_once_with(
|
||||
provider_id=str(aws_provider.id), tenant_id=ANY
|
||||
)
|
||||
mock_task_get.assert_called_once_with(id=prowler_task.id)
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.check_provider_connection_task.delay")
|
||||
def test_provider_connection_out_of_scope_returns_404(
|
||||
self,
|
||||
mock_provider_connection,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
tasks_fixture,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_provider_connection.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("provider-connection", kwargs={"pk": hidden_provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_provider_connection.assert_not_called()
|
||||
mock_task_get.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.check_provider_connection_task.delay")
|
||||
def test_provider_connection_in_scope_returns_202(
|
||||
self,
|
||||
mock_provider_connection,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_provider_connection.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("provider-connection", kwargs={"pk": aws_provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_provider_connection.assert_called_once_with(
|
||||
provider_id=str(aws_provider.id), tenant_id=ANY
|
||||
)
|
||||
mock_task_get.assert_called_once_with(id=prowler_task.id)
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.schedule_provider_scan")
|
||||
def test_schedule_daily_out_of_scope_returns_404(
|
||||
self,
|
||||
mock_schedule_scan,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
tasks_fixture,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_schedule_scan.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("schedule-daily"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "daily-schedules",
|
||||
"attributes": {"provider_id": str(hidden_provider.id)},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.wsgi_request.content_type == "application/vnd.api+json"
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_schedule_scan.assert_not_called()
|
||||
mock_task_get.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.schedule_provider_scan")
|
||||
def test_schedule_daily_in_scope_returns_202(
|
||||
self,
|
||||
mock_schedule_scan,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_schedule_scan.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("schedule-daily"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "daily-schedules",
|
||||
"attributes": {"provider_id": str(aws_provider.id)},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.wsgi_request.content_type == "application/vnd.api+json"
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_schedule_scan.assert_called_once_with(aws_provider)
|
||||
mock_task_get.assert_called_once_with(id=prowler_task.id)
|
||||
|
||||
def test_integrations(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture
|
||||
):
|
||||
|
||||
@@ -3,9 +3,11 @@ import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from threading import Event, Lock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
@@ -65,6 +67,7 @@ from api.v1.views import (
|
||||
)
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
from celery import states
|
||||
from celery.utils.saferepr import saferepr
|
||||
from conftest import (
|
||||
API_JSON_CONTENT_TYPE,
|
||||
TEST_PASSWORD,
|
||||
@@ -73,7 +76,7 @@ from conftest import (
|
||||
today_after_n_days,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db import close_old_connections, connection
|
||||
from django.db.models import Count
|
||||
from django.http import JsonResponse
|
||||
from django.test import RequestFactory
|
||||
@@ -5043,11 +5046,60 @@ class TestTaskViewSet:
|
||||
reverse("task-detail", kwargs={"pk": task1.id}),
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["task_args"] == {
|
||||
"kwarg1": "value1"
|
||||
}
|
||||
assert (
|
||||
response.json()["data"]["attributes"]["name"]
|
||||
== task1.task_runner_task.task_name
|
||||
)
|
||||
|
||||
def test_tasks_retrieve_hides_tenant_id(
|
||||
self, authenticated_client, tasks_fixture, tenants_fixture
|
||||
):
|
||||
task, *_ = tasks_fixture
|
||||
task.task_runner_task.task_kwargs = json.dumps(
|
||||
repr(
|
||||
{
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
"label": "True North",
|
||||
}
|
||||
)
|
||||
)
|
||||
task.task_runner_task.save(update_fields=["task_kwargs"])
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("task-detail", kwargs={"pk": task.id}),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["task_args"] == {
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
"label": "True North",
|
||||
}
|
||||
|
||||
def test_tasks_retrieve_with_truncated_kwargs_returns_empty_task_args(
|
||||
self, authenticated_client, tasks_fixture
|
||||
):
|
||||
task, *_ = tasks_fixture
|
||||
kwargs_repr = saferepr(
|
||||
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
|
||||
)
|
||||
assert "..." in kwargs_repr
|
||||
task.task_runner_task.task_kwargs = json.dumps(kwargs_repr)
|
||||
task.task_runner_task.save(update_fields=["task_kwargs"])
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("task-detail", kwargs={"pk": task.id}),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.headers["Content-Type"] == API_JSON_CONTENT_TYPE
|
||||
assert response.json()["data"]["attributes"]["task_args"] == {}
|
||||
|
||||
def test_tasks_invalid_retrieve(self, authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
reverse("task-detail", kwargs={"pk": "invalid_id"})
|
||||
@@ -14793,19 +14845,94 @@ class TestTenantFinishACSView:
|
||||
# Verify no new role was created
|
||||
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
|
||||
|
||||
def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing(
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"existing_role_attributes",
|
||||
"existing_suffixes",
|
||||
"expected_role_name",
|
||||
"expected_role_created",
|
||||
),
|
||||
[
|
||||
(None, (), "read_only", True),
|
||||
({"unlimited_visibility": True}, (), "read_only", False),
|
||||
(
|
||||
{"manage_users": True, "unlimited_visibility": True},
|
||||
(
|
||||
("read_only_0", {"unlimited_visibility": True}),
|
||||
("read_only_1", {"unlimited_visibility": True}),
|
||||
),
|
||||
"read_only_0",
|
||||
False,
|
||||
),
|
||||
(
|
||||
{"manage_users": True, "unlimited_visibility": True},
|
||||
(
|
||||
(
|
||||
"read_only_0",
|
||||
{"manage_users": True, "unlimited_visibility": True},
|
||||
),
|
||||
("read_only_1", {"unlimited_visibility": True}),
|
||||
),
|
||||
"read_only_1",
|
||||
False,
|
||||
),
|
||||
({"unlimited_visibility": False}, (), "read_only_0", True),
|
||||
],
|
||||
ids=[
|
||||
"creates-role",
|
||||
"reuses-safe-role",
|
||||
"reuses-first-safe-suffixed-role",
|
||||
"skips-unsafe-suffixed-role",
|
||||
"avoids-restricted-visibility",
|
||||
],
|
||||
)
|
||||
def test_dispatch_assigns_read_only_role_when_usertype_missing(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
existing_role_attributes,
|
||||
existing_suffixes,
|
||||
expected_role_name,
|
||||
expected_role_created,
|
||||
):
|
||||
"""Test that a user without roles gets none assigned when userType is missing"""
|
||||
"""Test safe fallback role assignment when userType is missing"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
roles_before = Role.objects.using(MainRouter.admin_db).count()
|
||||
other_tenant = tenants_fixture[1]
|
||||
|
||||
other_tenant_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="read_only",
|
||||
tenant=other_tenant,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
other_tenant_relationship = UserRoleRelationship.objects.using(
|
||||
MainRouter.admin_db
|
||||
).create(
|
||||
user=user,
|
||||
role=other_tenant_role,
|
||||
tenant=other_tenant,
|
||||
)
|
||||
|
||||
existing_role = None
|
||||
if existing_role_attributes is not None:
|
||||
existing_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="read_only",
|
||||
tenant=tenant,
|
||||
**existing_role_attributes,
|
||||
)
|
||||
for role_name, role_attributes in existing_suffixes:
|
||||
Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant=tenant,
|
||||
**role_attributes,
|
||||
)
|
||||
roles_before = (
|
||||
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
@@ -14858,12 +14985,44 @@ class TestTenantFinishACSView:
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# Verify no role was created or assigned
|
||||
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
|
||||
assert not (
|
||||
# Verify the fallback role was created or reused with read-only access
|
||||
expected_role_count = roles_before + expected_role_created
|
||||
assert (
|
||||
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
|
||||
== expected_role_count
|
||||
)
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=expected_role_name, tenant=tenant
|
||||
)
|
||||
if existing_role is not None and expected_role_name == "read_only":
|
||||
assert role == existing_role
|
||||
assert not role.manage_users
|
||||
assert not role.manage_account
|
||||
assert not role.manage_billing
|
||||
assert not role.manage_providers
|
||||
assert not role.manage_integrations
|
||||
assert not role.manage_scans
|
||||
assert role.unlimited_visibility
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(
|
||||
id=other_tenant_relationship.id,
|
||||
user=user,
|
||||
role=other_tenant_role,
|
||||
tenant_id=other_tenant.id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, tenant_id=tenant.id)
|
||||
.exists()
|
||||
.count()
|
||||
== 1
|
||||
)
|
||||
|
||||
# Membership is still created so the user belongs to the tenant
|
||||
@@ -14873,6 +15032,131 @@ class TestTenantFinishACSView:
|
||||
.exists()
|
||||
)
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_dispatch_serializes_concurrent_fallback_role_assignment(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
saml_setup,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test concurrent callbacks assign only one fallback role"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
Role.objects.using(MainRouter.admin_db).create(
|
||||
name="read_only",
|
||||
tenant=tenant,
|
||||
manage_users=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
},
|
||||
)
|
||||
# Without the user lock, both callbacks reach this query before either
|
||||
# creates a fallback. With the lock, the first callback times out here
|
||||
# while the second waits for the transaction to finish.
|
||||
second_role_check_reached = Event()
|
||||
concurrent_role_checks_detected = Event()
|
||||
role_check_count_lock = Lock()
|
||||
role_check_count = 0
|
||||
original_role_check = TenantFinishACSView._user_has_tenant_role
|
||||
|
||||
def synchronize_role_checks(user_id, tenant_id):
|
||||
nonlocal role_check_count
|
||||
with role_check_count_lock:
|
||||
role_check_count += 1
|
||||
is_first_role_check = role_check_count == 1
|
||||
if role_check_count == 2:
|
||||
second_role_check_reached.set()
|
||||
if is_first_role_check and second_role_check_reached.wait(timeout=1):
|
||||
concurrent_role_checks_detected.set()
|
||||
return original_role_check(user_id, tenant_id)
|
||||
|
||||
def dispatch_callback():
|
||||
close_old_connections()
|
||||
try:
|
||||
thread_user = User.objects.using(MainRouter.admin_db).get(pk=user.pk)
|
||||
request = RequestFactory().get(
|
||||
reverse(
|
||||
"saml_finish_acs",
|
||||
kwargs={"organization_slug": saml_setup["domain"]},
|
||||
)
|
||||
)
|
||||
request.user = thread_user
|
||||
request.session = {}
|
||||
response = TenantFinishACSView.as_view()(
|
||||
request, organization_slug=saml_setup["domain"]
|
||||
)
|
||||
return response
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
patch.object(
|
||||
TenantFinishACSView,
|
||||
"_user_has_tenant_role",
|
||||
side_effect=synchronize_role_checks,
|
||||
),
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml",
|
||||
client_id=saml_setup["domain"],
|
||||
name="Test App",
|
||||
settings={},
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
|
||||
mock_saml_config_get.return_value = SimpleNamespace(
|
||||
email_domain=saml_setup["domain"], tenant=tenant
|
||||
)
|
||||
mock_user_get.side_effect = lambda *_args, **_kwargs: User.objects.using(
|
||||
MainRouter.admin_db
|
||||
).get(pk=user.pk)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
responses = list(executor.map(lambda _: dispatch_callback(), range(2)))
|
||||
|
||||
assert role_check_count == 2
|
||||
assert not concurrent_role_checks_detected.is_set()
|
||||
for response in responses:
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
parsed_redirect = urlparse(response.url)
|
||||
assert parsed_redirect.path == "/sso-complete"
|
||||
assert set(parse_qs(parsed_redirect.query)) == {"id"}
|
||||
relationships = UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
|
||||
user=user, tenant_id=tenant.id
|
||||
)
|
||||
assert relationships.count() == 1
|
||||
assert relationships.get().role.name == "read_only_0"
|
||||
assert (
|
||||
Role.objects.using(MainRouter.admin_db)
|
||||
.filter(tenant=tenant, name__startswith="read_only_")
|
||||
.count()
|
||||
== 1
|
||||
)
|
||||
|
||||
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role(
|
||||
self,
|
||||
create_test_user,
|
||||
@@ -15748,6 +16032,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"))
|
||||
|
||||
@@ -6,9 +6,11 @@ from api.exceptions import (
|
||||
TaskNotFoundException,
|
||||
)
|
||||
from api.models import Provider, StateChoices, Task
|
||||
from api.rbac.permissions import get_providers
|
||||
from api.v1.serializers import TaskSerializer
|
||||
from django.http import QueryDict
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
@@ -33,6 +35,22 @@ class DisablePaginationMixin:
|
||||
return super().paginate_queryset(queryset)
|
||||
|
||||
|
||||
class ProviderVisibilityMixin:
|
||||
@cached_property
|
||||
def provider_queryset(self):
|
||||
if self.user_role.unlimited_visibility:
|
||||
return Provider.objects.filter(tenant_id=self.request.tenant_id)
|
||||
return get_providers(self.user_role)
|
||||
|
||||
def get_provider_queryset(self):
|
||||
return self.provider_queryset
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context["provider_queryset"] = self.get_provider_queryset()
|
||||
return context
|
||||
|
||||
|
||||
class PaginateByPkMixin:
|
||||
"""
|
||||
Mixin to paginate on a list of PKs (cheaper than heavy JOINs),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import yaml
|
||||
from api.celery_utils import decode_celery_field
|
||||
from api.db_router import MainRouter
|
||||
from api.exceptions import ConflictException
|
||||
from api.models import (
|
||||
@@ -59,6 +61,7 @@ from api.v1.serializer_utils.lighthouse import (
|
||||
from api.v1.serializer_utils.processors import ProcessorConfigField
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.validators import validate_lighthouse_openai_compatible_base_url
|
||||
from config.custom_logging import BackendLogger
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.models import update_last_login
|
||||
@@ -79,6 +82,8 @@ from rest_framework_simplejwt.settings import api_settings
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from rest_framework_simplejwt.utils import get_md5_hash_password
|
||||
|
||||
logger = logging.getLogger(BackendLogger.API)
|
||||
|
||||
# Base
|
||||
|
||||
|
||||
@@ -124,6 +129,20 @@ class RLSSerializer(BaseModelSerializerV1):
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class ScopedProviderFieldMixin:
|
||||
provider_field_name = "provider"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
provider_queryset = self.context.get("provider_queryset")
|
||||
provider_field = self.fields.get(self.provider_field_name)
|
||||
if provider_queryset is None or provider_field is None:
|
||||
return
|
||||
|
||||
related_field = getattr(provider_field, "child_relation", provider_field)
|
||||
related_field.queryset = provider_queryset
|
||||
|
||||
|
||||
class StateEnumSerializerField(serializers.ChoiceField):
|
||||
def __init__(self, **kwargs):
|
||||
kwargs["choices"] = StateChoices.choices
|
||||
@@ -607,13 +626,24 @@ class TaskSerializer(RLSSerializer, TaskBase):
|
||||
|
||||
@extend_schema_field(serializers.JSONField())
|
||||
def get_task_args(self, obj):
|
||||
task_args = self.get_json_field(obj, "task_kwargs")
|
||||
# Celery task_kwargs are stored as a double string JSON in the database when not empty
|
||||
if isinstance(task_args, str):
|
||||
task_args = json.loads(task_args.replace("'", '"').replace("None", "null"))
|
||||
# Remove tenant_id from task_kwargs if present
|
||||
task_args.pop("tenant_id", None)
|
||||
task_kwargs = (
|
||||
getattr(obj.task_runner_task, "task_kwargs", None)
|
||||
if obj.task_runner_task
|
||||
else None
|
||||
)
|
||||
try:
|
||||
task_args = decode_celery_field(task_kwargs, {})
|
||||
if not isinstance(task_args, dict):
|
||||
raise ValueError("Decoded task kwargs must be a dictionary")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Unable to decode task kwargs for task %s; returning empty task_args.",
|
||||
obj.id,
|
||||
)
|
||||
return {}
|
||||
|
||||
task_args = task_args.copy()
|
||||
task_args.pop("tenant_id", None)
|
||||
return task_args
|
||||
|
||||
@staticmethod
|
||||
@@ -693,7 +723,10 @@ class MembershipIncludeSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
# Provider Groups
|
||||
class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
class ProviderGroupSerializer(
|
||||
ScopedProviderFieldMixin, RLSSerializer, BaseWriteSerializer
|
||||
):
|
||||
provider_field_name = "providers"
|
||||
providers = serializers.ResourceRelatedField(
|
||||
queryset=Provider.objects.all(), many=True, required=False
|
||||
)
|
||||
@@ -851,9 +884,27 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
help_text="List of resource identifier objects representing providers.",
|
||||
)
|
||||
|
||||
def get_providers(self, validated_data):
|
||||
provider_ids = {item["id"] for item in validated_data["providers"]}
|
||||
provider_queryset = self.context.get("provider_queryset")
|
||||
if provider_queryset is None:
|
||||
provider_queryset = Provider.objects.filter(
|
||||
tenant_id=self.context.get("tenant_id")
|
||||
)
|
||||
|
||||
providers = list(provider_queryset.filter(id__in=provider_ids))
|
||||
if {provider.id for provider in providers} != provider_ids:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"providers": (
|
||||
"One or more providers do not exist or are not accessible."
|
||||
)
|
||||
}
|
||||
)
|
||||
return providers
|
||||
|
||||
def create(self, validated_data):
|
||||
provider_ids = [item["id"] for item in validated_data["providers"]]
|
||||
providers = Provider.objects.filter(id__in=provider_ids)
|
||||
providers = self.get_providers(validated_data)
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
|
||||
new_relationships = [
|
||||
@@ -869,8 +920,7 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
return self.context.get("provider_group")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
provider_ids = [item["id"] for item in validated_data["providers"]]
|
||||
providers = Provider.objects.filter(id__in=provider_ids)
|
||||
providers = self.get_providers(validated_data)
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
|
||||
instance.providers.clear()
|
||||
@@ -1109,7 +1159,9 @@ class ScanIncludeSerializer(RLSSerializer):
|
||||
}
|
||||
|
||||
|
||||
class ScanCreateSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
class ScanCreateSerializer(
|
||||
ScopedProviderFieldMixin, RLSSerializer, BaseWriteSerializer
|
||||
):
|
||||
class Meta:
|
||||
model = Scan
|
||||
# TODO: add mutelist when implemented
|
||||
@@ -1974,7 +2026,9 @@ class ProviderSecretSerializer(RLSSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSerializer):
|
||||
class ProviderSecretCreateSerializer(
|
||||
ScopedProviderFieldMixin, RLSSerializer, BaseWriteProviderSecretSerializer
|
||||
):
|
||||
secret = ProviderSecretField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -146,6 +146,7 @@ from api.v1.mixins import (
|
||||
JsonApiFilterMixin,
|
||||
PaginateByPkMixin,
|
||||
ProviderFilterParamsMixin,
|
||||
ProviderVisibilityMixin,
|
||||
TaskManagementMixin,
|
||||
)
|
||||
from api.v1.serializers import (
|
||||
@@ -813,6 +814,21 @@ class TenantFinishACSView(FinishACSView):
|
||||
User.objects.using(MainRouter.admin_db).filter(id=saml_user_id).delete()
|
||||
request.session.pop("saml_user_created", None)
|
||||
|
||||
@staticmethod
|
||||
def _user_has_tenant_role(user_id, tenant_id):
|
||||
return (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user_id=user_id, tenant_id=tenant_id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_read_only_fallback_role(role):
|
||||
return (
|
||||
not any(getattr(role, permission) for permission in Role.PERMISSION_FIELDS)
|
||||
and role.unlimited_visibility
|
||||
)
|
||||
|
||||
def dispatch(self, request, organization_slug):
|
||||
try:
|
||||
super().dispatch(request, organization_slug)
|
||||
@@ -878,11 +894,56 @@ class TenantFinishACSView(FinishACSView):
|
||||
user.name = "N/A"
|
||||
user.save()
|
||||
|
||||
# Only remap roles when the IdP provides a userType attribute.
|
||||
# Without it, the user's current roles are left untouched.
|
||||
# Only remap existing roles when the IdP provides a userType attribute.
|
||||
# Without it, preserve current roles or assign a read-only fallback.
|
||||
role_name = (
|
||||
extra.get("userType", [""])[0].strip() if extra.get("userType") else ""
|
||||
)
|
||||
if not role_name:
|
||||
with rls_transaction(str(tenant.id), using=MainRouter.admin_db):
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
# Serialize concurrent ACS callbacks for the same user.
|
||||
(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.select_for_update()
|
||||
.only("id")
|
||||
.get(pk=user_id)
|
||||
)
|
||||
user_has_roles = self._user_has_tenant_role(user_id, tenant.id)
|
||||
if not user_has_roles:
|
||||
read_only_defaults = dict.fromkeys(
|
||||
Role.PERMISSION_FIELDS, False
|
||||
)
|
||||
read_only_defaults["unlimited_visibility"] = True
|
||||
role, role_created = Role.objects.using(
|
||||
MainRouter.admin_db
|
||||
).get_or_create(
|
||||
name="read_only",
|
||||
tenant=tenant,
|
||||
defaults=read_only_defaults,
|
||||
)
|
||||
role_is_read_only = self._is_read_only_fallback_role(role)
|
||||
if not role_created and not role_is_read_only:
|
||||
suffix = 0
|
||||
while not role_created and not role_is_read_only:
|
||||
role, role_created = Role.objects.using(
|
||||
MainRouter.admin_db
|
||||
).get_or_create(
|
||||
name=f"read_only_{suffix}",
|
||||
tenant=tenant,
|
||||
defaults=read_only_defaults,
|
||||
)
|
||||
role_is_read_only = self._is_read_only_fallback_role(
|
||||
role
|
||||
)
|
||||
suffix += 1
|
||||
UserRoleRelationship.objects.using(
|
||||
MainRouter.admin_db
|
||||
).get_or_create(
|
||||
user=user,
|
||||
role=role,
|
||||
defaults={"tenant": tenant},
|
||||
)
|
||||
if role_name:
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
role = (
|
||||
@@ -1632,7 +1693,7 @@ class TenantMembersViewSet(BaseTenantViewset):
|
||||
),
|
||||
update=extend_schema(exclude=True),
|
||||
)
|
||||
class ProviderGroupViewSet(BaseRLSViewSet):
|
||||
class ProviderGroupViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
queryset = ProviderGroup.objects.all()
|
||||
serializer_class = ProviderGroupSerializer
|
||||
filterset_class = ProviderGroupFilter
|
||||
@@ -1653,14 +1714,13 @@ class ProviderGroupViewSet(BaseRLSViewSet):
|
||||
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
# Check if any of the user's roles have UNLIMITED_VISIBILITY
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all provider groups
|
||||
return ProviderGroup.objects.prefetch_related("providers", "roles")
|
||||
|
||||
# Collect provider groups associated with the user's roles
|
||||
return user_roles.provider_groups.all().prefetch_related("providers", "roles")
|
||||
if self.user_role.unlimited_visibility:
|
||||
queryset = ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
|
||||
else:
|
||||
queryset = self.user_role.provider_groups.filter(
|
||||
tenant_id=self.request.tenant_id
|
||||
)
|
||||
return queryset.prefetch_related("providers", "roles")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
@@ -1701,7 +1761,9 @@ class ProviderGroupViewSet(BaseRLSViewSet):
|
||||
},
|
||||
),
|
||||
)
|
||||
class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
class ProviderGroupProvidersRelationshipView(
|
||||
ProviderVisibilityMixin, RelationshipView, BaseRLSViewSet
|
||||
):
|
||||
queryset = ProviderGroup.objects.all()
|
||||
serializer_class = ProviderGroupMembershipSerializer
|
||||
resource_name = "providers"
|
||||
@@ -1711,7 +1773,9 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def get_queryset(self):
|
||||
return ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
|
||||
if self.user_role.unlimited_visibility:
|
||||
return ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
|
||||
return self.user_role.provider_groups.filter(tenant_id=self.request.tenant_id)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
provider_group = self.get_object()
|
||||
@@ -1733,6 +1797,7 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
data={"providers": request.data},
|
||||
context={
|
||||
"provider_group": provider_group,
|
||||
"provider_queryset": self.get_provider_queryset(),
|
||||
"tenant_id": self.request.tenant_id,
|
||||
"request": request,
|
||||
},
|
||||
@@ -1747,7 +1812,11 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
serializer = self.get_serializer(
|
||||
instance=provider_group,
|
||||
data={"providers": request.data},
|
||||
context={"tenant_id": self.request.tenant_id, "request": request},
|
||||
context={
|
||||
"provider_queryset": self.get_provider_queryset(),
|
||||
"tenant_id": self.request.tenant_id,
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
@@ -1864,7 +1933,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_name="connection")
|
||||
def connection(self, request, pk=None):
|
||||
get_object_or_404(Provider, pk=pk)
|
||||
self.get_object()
|
||||
with transaction.atomic():
|
||||
task = check_provider_connection_task.delay(
|
||||
provider_id=pk, tenant_id=self.request.tenant_id
|
||||
@@ -1882,7 +1951,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
)
|
||||
|
||||
def destroy(self, request, *args, pk=None, **kwargs):
|
||||
provider = get_object_or_404(Provider, pk=pk)
|
||||
provider = self.get_object()
|
||||
provider.is_deleted = True
|
||||
provider.save()
|
||||
task_name = f"scan-perform-scheduled-{pk}"
|
||||
@@ -2104,7 +2173,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
class ScanViewSet(BaseRLSViewSet):
|
||||
class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
queryset = Scan.objects.all()
|
||||
serializer_class = ScanSerializer
|
||||
http_method_names = ["get", "post", "patch"]
|
||||
@@ -2133,13 +2202,7 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
self.required_permissions = [Permissions.MANAGE_SCANS]
|
||||
|
||||
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 scans
|
||||
queryset = Scan.objects.filter(tenant_id=self.request.tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter providers based on provider groups associated with the role
|
||||
queryset = Scan.objects.filter(provider__in=get_providers(user_roles))
|
||||
queryset = Scan.objects.filter(provider__in=self.get_provider_queryset())
|
||||
return queryset.select_related("provider", "task")
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -2737,6 +2800,7 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
provider = Provider.objects.select_for_update().get(
|
||||
id=provider.id,
|
||||
tenant_id=self.request.tenant_id,
|
||||
id__in=self.get_provider_queryset().values("id"),
|
||||
)
|
||||
active_scan = get_active_provider_scan(
|
||||
self.request.tenant_id, provider.id
|
||||
@@ -4311,7 +4375,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
class ProviderSecretViewSet(BaseRLSViewSet):
|
||||
class ProviderSecretViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
queryset = ProviderSecret.objects.all()
|
||||
serializer_class = ProviderSecretSerializer
|
||||
filterset_class = ProviderSecretFilter
|
||||
@@ -4327,7 +4391,7 @@ class ProviderSecretViewSet(BaseRLSViewSet):
|
||||
required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def get_queryset(self):
|
||||
return ProviderSecret.objects.filter(tenant_id=self.request.tenant_id)
|
||||
return ProviderSecret.objects.filter(provider__in=self.get_provider_queryset())
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
@@ -6608,7 +6672,7 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
)
|
||||
)
|
||||
class ScheduleViewSet(BaseRLSViewSet):
|
||||
class ScheduleViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
# TODO: change to Schedule when implemented
|
||||
queryset = Task.objects.none()
|
||||
http_method_names = ["post"]
|
||||
@@ -6635,7 +6699,9 @@ class ScheduleViewSet(BaseRLSViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
provider_id = serializer.validated_data["provider_id"]
|
||||
|
||||
provider_instance = get_object_or_404(Provider, pk=provider_id)
|
||||
provider_instance = get_object_or_404(
|
||||
self.get_provider_queryset(), pk=provider_id
|
||||
)
|
||||
with transaction.atomic():
|
||||
task = schedule_provider_scan(provider_instance)
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ API_JSON_CONTENT_TYPE = "application/vnd.api+json"
|
||||
NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED
|
||||
TEST_USER = "dev@prowler.com"
|
||||
TEST_PASSWORD = "testing_psswd"
|
||||
TEST_ADMIN_ALIAS = "admin"
|
||||
TEST_REPLICA_ALIAS = "test_replica"
|
||||
|
||||
|
||||
@@ -2539,26 +2540,36 @@ def finding_groups_title_variants_fixture(
|
||||
return findings
|
||||
|
||||
|
||||
def _ensure_mirrored_test_alias(alias: str) -> None:
|
||||
default_database = settings.DATABASES["default"]
|
||||
if alias not in settings.DATABASES:
|
||||
settings.DATABASES[alias] = {
|
||||
**default_database,
|
||||
"TEST": {
|
||||
**default_database.get("TEST", {}),
|
||||
"MIRROR": "default",
|
||||
},
|
||||
}
|
||||
django_connections.databases[alias] = settings.DATABASES[alias]
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items):
|
||||
"""Ensure test_rbac.py is executed first."""
|
||||
items.sort(key=lambda item: 0 if "test_rbac.py" in item.nodeid else 1)
|
||||
|
||||
if any(item.get_closest_marker("requires_test_admin_alias") for item in items):
|
||||
_ensure_mirrored_test_alias(TEST_ADMIN_ALIAS)
|
||||
|
||||
if any(item.get_closest_marker("requires_test_replica_alias") for item in items):
|
||||
default_database = settings.DATABASES["default"]
|
||||
if TEST_REPLICA_ALIAS not in settings.DATABASES:
|
||||
settings.DATABASES[TEST_REPLICA_ALIAS] = {
|
||||
**default_database,
|
||||
"TEST": {
|
||||
**default_database.get("TEST", {}),
|
||||
"MIRROR": "default",
|
||||
},
|
||||
}
|
||||
django_connections.databases[TEST_REPLICA_ALIAS] = settings.DATABASES[
|
||||
TEST_REPLICA_ALIAS
|
||||
]
|
||||
_ensure_mirrored_test_alias(TEST_REPLICA_ALIAS)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"requires_test_admin_alias: creates a test-only admin alias mirrored "
|
||||
"to default",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"requires_test_replica_alias: creates a test-only replica alias mirrored "
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -18,12 +18,11 @@ This is the shared engine behind both the periodic Beat watchdog and the
|
||||
`reconcile_orphan_tasks` management command.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from api.celery_utils import decode_celery_field
|
||||
from celery import current_app, states
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.db import connections
|
||||
@@ -138,34 +137,6 @@ def revoke_task(task_result, terminate: bool = True) -> None:
|
||||
logger.exception(f"Failed to revoke task {task_result.task_id}")
|
||||
|
||||
|
||||
def _decode_celery_field(value, default):
|
||||
"""Decode django-celery-results' stored task_args/task_kwargs to a Python object.
|
||||
|
||||
The backend stores them as a (sometimes double-encoded) repr/JSON string. An
|
||||
empty or missing field returns ``default``; a non-empty value that cannot be
|
||||
decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with
|
||||
the wrong arguments.
|
||||
"""
|
||||
obj = value
|
||||
for _ in range(2): # values can be double-encoded (a string holding a repr)
|
||||
if not isinstance(obj, str):
|
||||
break
|
||||
text = obj.strip()
|
||||
if not text:
|
||||
return default
|
||||
parsed = None
|
||||
for parser in (ast.literal_eval, json.loads):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
break
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
continue
|
||||
if parsed is None:
|
||||
raise ValueError(f"undecodable celery field: {text[:120]!r}")
|
||||
obj = parsed
|
||||
return default if obj is None else obj
|
||||
|
||||
|
||||
def reconcile_orphans(
|
||||
grace_minutes: int = 2,
|
||||
max_attempts: int = 3,
|
||||
@@ -313,8 +284,10 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
args = _decode_celery_field(args_repr, [])
|
||||
kwargs = _decode_celery_field(kwargs_repr, {})
|
||||
args = decode_celery_field(args_repr, [])
|
||||
kwargs = decode_celery_field(kwargs_repr, {})
|
||||
if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict):
|
||||
raise ValueError("Stored task arguments have invalid types")
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing",
|
||||
@@ -324,8 +297,8 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
|
||||
return "failed"
|
||||
new_task_id = str(uuid4())
|
||||
task_obj.apply_async(
|
||||
args=list(args) if isinstance(args, (list, tuple)) else [],
|
||||
kwargs=kwargs if isinstance(kwargs, dict) else {},
|
||||
args=list(args),
|
||||
kwargs=kwargs,
|
||||
task_id=new_task_id,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,12 +3,13 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.celery_utils import decode_celery_field
|
||||
from celery import states
|
||||
from celery.utils.saferepr import saferepr
|
||||
from django.test import override_settings
|
||||
from django_celery_results.models import TaskResult
|
||||
from tasks.jobs.orphan_recovery import (
|
||||
_SKIP_RECOVERY,
|
||||
_decode_celery_field,
|
||||
_reconcile_task_results,
|
||||
_recovery_attempt_count,
|
||||
advisory_lock,
|
||||
@@ -36,24 +37,77 @@ def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.S
|
||||
return tr
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDecodeCeleryField:
|
||||
def test_decodes_strict_json(self):
|
||||
assert decode_celery_field('{"enabled": true, "scan_id": null}', {}) == {
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
}
|
||||
|
||||
def test_decodes_single_encoded_repr(self):
|
||||
assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
|
||||
assert decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
|
||||
|
||||
def test_decodes_double_encoded(self):
|
||||
import json
|
||||
|
||||
stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"}))
|
||||
assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"}
|
||||
assert decode_celery_field(stored, {}) == {
|
||||
"tenant_id": "abc",
|
||||
"scan_id": "s1",
|
||||
}
|
||||
|
||||
def test_python_words_inside_strings_are_preserved(self):
|
||||
stored = repr(
|
||||
{
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
"label": "True North",
|
||||
"note": "None",
|
||||
}
|
||||
)
|
||||
|
||||
assert decode_celery_field(stored, {}) == {
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
"label": "True North",
|
||||
"note": "None",
|
||||
}
|
||||
|
||||
def test_empty_returns_default(self):
|
||||
assert _decode_celery_field(None, {}) == {}
|
||||
assert _decode_celery_field("", []) == []
|
||||
assert decode_celery_field(None, {}) == {}
|
||||
assert decode_celery_field("", []) == []
|
||||
assert decode_celery_field("null", {}) == {}
|
||||
assert decode_celery_field("None", []) == []
|
||||
|
||||
def test_empty_validates_default(self):
|
||||
with pytest.raises(ValueError):
|
||||
decode_celery_field("", {"value": ...})
|
||||
|
||||
def test_unparseable_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_decode_celery_field("<<not a literal>>", {})
|
||||
decode_celery_field("<<not a literal>>", {})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
(
|
||||
"{'value': ...}",
|
||||
"{'value': {1, 2}}",
|
||||
"{'value': b'bytes'}",
|
||||
'{"value": NaN}',
|
||||
),
|
||||
)
|
||||
def test_non_json_values_raise(self, value):
|
||||
with pytest.raises(ValueError):
|
||||
decode_celery_field(value, {})
|
||||
|
||||
def test_truncated_repr_raises(self):
|
||||
kwargs_repr = saferepr(
|
||||
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
|
||||
)
|
||||
assert "..." in kwargs_repr
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
decode_celery_field(kwargs_repr, {})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -98,6 +152,58 @@ class TestReconcileTaskResults:
|
||||
assert call["kwargs"] == {"tenant_id": str(tenant.id)}
|
||||
assert call["task_id"] != tr.task_id # fresh task id
|
||||
|
||||
def test_truncated_kwargs_are_not_reenqueued(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
tr = _orphan_result(
|
||||
name="tenant-deletion",
|
||||
kwargs={"tenant_id": str(tenant.id)},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
tr.task_kwargs = saferepr(
|
||||
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
|
||||
)
|
||||
assert "..." in tr.task_kwargs
|
||||
tr.save(update_fields=["task_kwargs"])
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["failed"]
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
def test_wrong_kwargs_shape_is_not_reenqueued(self, tenants_fixture):
|
||||
tr = _orphan_result(
|
||||
name="tenant-deletion",
|
||||
kwargs={"tenant_id": str(tenants_fixture[0].id)},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
tr.task_kwargs = "[]"
|
||||
tr.save(update_fields=["task_kwargs"])
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["failed"]
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
def test_external_integration_task_is_not_reenqueued_by_default(
|
||||
self, tenants_fixture
|
||||
):
|
||||
|
||||
@@ -7,5 +7,11 @@ component_management:
|
||||
paths:
|
||||
- "api/**"
|
||||
|
||||
flags:
|
||||
api:
|
||||
paths:
|
||||
- "api/**"
|
||||
carryforward: true
|
||||
|
||||
comment:
|
||||
layout: "header, diff, flags, components"
|
||||
@@ -143,6 +143,7 @@
|
||||
"group": "Prowler Cloud",
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-app",
|
||||
"user-guide/tutorials/prowler-cloud-feedback",
|
||||
{
|
||||
"group": "Authentication & Access",
|
||||
"pages": [
|
||||
@@ -164,6 +165,7 @@
|
||||
"group": "Compliance",
|
||||
"pages": [
|
||||
"user-guide/compliance/tutorials/compliance",
|
||||
"user-guide/compliance/tutorials/cross-provider-type-compliance",
|
||||
"user-guide/compliance/tutorials/cross-provider-compliance",
|
||||
"user-guide/compliance/tutorials/threatscore"
|
||||
]
|
||||
|
||||
@@ -6,11 +6,16 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|
||||
|
||||
## Tool Categories Summary
|
||||
|
||||
| Category | Tool Count | Authentication Required |
|
||||
|----------|------------|------------------------|
|
||||
| Prowler Hub | 10 tools | No |
|
||||
| Prowler Documentation | 2 tools | No |
|
||||
| Prowler Cloud, Private Cloud & Local Server | 39 tools | Yes |
|
||||
| Category | Tool Count | Authentication Required | Availability |
|
||||
|----------|------------|------------------------|--------------|
|
||||
| Prowler Hub | 10 tools | No | Cloud and Local MCP Server |
|
||||
| Prowler Documentation | 2 tools | No | Cloud and Local MCP Server |
|
||||
| Prowler Cloud, Private Cloud & Local Server | 49 tools | Yes | Cloud and Local MCP Server |
|
||||
| Prowler Cloud management | 32 tools | Yes | Cloud MCP Server only |
|
||||
|
||||
<Note>
|
||||
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools.
|
||||
</Note>
|
||||
|
||||
## Tool Naming Convention
|
||||
|
||||
@@ -19,6 +24,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_*` - Prowler Cloud, Prowler Private Cloud & Prowler Local Server management tools
|
||||
- `prowler_cloud_*` - Prowler Cloud-only management tools
|
||||
|
||||
<Note>
|
||||
`prowler_cloud_*` tools are exposed only by the [Cloud MCP Server](/getting-started/products/prowler-mcp#cloud-vs-local-mcp-server) at `https://mcp.prowler.com/mcp`, because they manage features that exist only in Prowler Cloud. Every other tool is available on both the Cloud and Local MCP Server.
|
||||
</Note>
|
||||
|
||||
## Prowler Tools
|
||||
|
||||
@@ -59,9 +69,13 @@ Tools for managing and monitoring security scans.
|
||||
- **`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_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring (**Local MCP Server only**)
|
||||
- **`prowler_update_scan`** - Update scan name for better organization
|
||||
|
||||
<Note>
|
||||
`prowler_schedule_daily_scan` is the scheduling tool for a self-hosted deployment, and it only does one thing: a daily scan. The Cloud MCP Server does not expose it — Prowler Cloud replaces it with the richer [Scan Scheduling](#scan-scheduling) tools, which add interval, weekly, and monthly frequencies, per-provider schedule retrieval, and bulk apply across providers.
|
||||
</Note>
|
||||
|
||||
### Resources Management
|
||||
|
||||
Tools for searching, viewing, and analyzing cloud resources discovered by Prowler.
|
||||
@@ -89,6 +103,29 @@ Tools for managing finding muting, including pattern-based bulk muting (mutelist
|
||||
- **`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
|
||||
|
||||
### Integrations Management
|
||||
|
||||
Tools for managing where Prowler sends its results: Amazon S3 buckets, AWS Security Hub, and Jira. Requires the **Manage Integrations** permission.
|
||||
|
||||
#### Integration Lifecycle
|
||||
|
||||
- **`prowler_list_integrations`** - List the configured integrations with their enabled and connection state, optionally filtered by integration type
|
||||
- **`prowler_get_integration`** - Get an integration with its complete, type-specific configuration (bucket and output directory, Security Hub settings and enabled regions, or Jira projects and issue types)
|
||||
- **`prowler_update_integration`** - Update credentials, configuration, attached providers, or enabled state. Configuration changes are merged with the current one, and the connection is re-checked automatically whenever credentials, configuration, or attached providers change
|
||||
- **`prowler_delete_integration`** - Permanently remove an integration and its stored credentials
|
||||
- **`prowler_test_integration_connection`** - Check an integration connection and refresh the configuration Prowler discovers from the remote system (Jira projects, Security Hub regions)
|
||||
|
||||
#### Integration Setup
|
||||
|
||||
- **`prowler_create_amazon_s3_integration`** - Export scan outputs (CSV, HTML, OCSF JSON, compliance reports) to an S3 bucket, using an IAM role or static credentials
|
||||
- **`prowler_create_aws_security_hub_integration`** - Send findings to AWS Security Hub in ASFF format for a single AWS provider, reusing the provider credentials or dedicated ones
|
||||
- **`prowler_create_jira_integration`** - Connect an Atlassian Jira site so findings can be turned into work items. Tenant-wide, not attached to any provider
|
||||
|
||||
#### Jira Operations
|
||||
|
||||
- **`prowler_get_jira_issue_types`** - List the issue types available in a Jira project, fetched live from Jira
|
||||
- **`prowler_send_findings_to_jira`** - Create one Jira work item per finding, with its severity, resource, risk, and remediation steps
|
||||
|
||||
### 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.
|
||||
@@ -122,6 +159,78 @@ Tools for browsing RBAC roles and managing the role assigned to a user. A user h
|
||||
- **`prowler_get_user_roles`** - List the roles assigned to a specific user, with the capabilities each role grants
|
||||
- **`prowler_set_user_role`** - Set the role a user holds, replacing the role they had before (idempotent)
|
||||
|
||||
## Prowler Cloud Tools
|
||||
|
||||
Manage Prowler Cloud-only features and configuration. **Requires authentication.**
|
||||
|
||||
<Note>
|
||||
These tools are available **only on the Cloud MCP Server** (`https://mcp.prowler.com/mcp`). A Local MCP Server does not expose them, because the features they manage exist only in Prowler Cloud.
|
||||
</Note>
|
||||
|
||||
### Scan Configurations
|
||||
|
||||
Tools for managing reusable scan configurations — per-provider check and compliance selections — and attaching them to providers. Providers without a configuration attached use the default.
|
||||
|
||||
- **`prowler_cloud_list_scan_configurations`** - List and filter the scan configurations defined in the tenant
|
||||
- **`prowler_cloud_get_scan_configuration`** - Retrieve a scan configuration including its full configuration body
|
||||
- **`prowler_cloud_get_scan_configuration_schema`** - Fetch the JSON Schema describing the keys a valid configuration body may set, optionally filtered to a single provider type
|
||||
- **`prowler_cloud_create_scan_configuration`** - Create a scan configuration and optionally attach it to providers
|
||||
- **`prowler_cloud_update_scan_configuration`** - Update a configuration's name, body, and/or attached providers
|
||||
- **`prowler_cloud_delete_scan_configuration`** - Delete a scan configuration; attached providers revert to the default
|
||||
|
||||
### Findings Triage
|
||||
|
||||
Tools for recording a review decision on a finding and documenting the reasoning. Triage is keyed on the stable finding UID returned by `prowler_search_security_findings` and `prowler_get_finding_details`.
|
||||
|
||||
<Note>
|
||||
Triage is distinct from [muting](#muting-management). Use mute rules and the mutelist to **suppress** findings; use triage to **record a decision** and its rationale while the finding stays visible. See the [Findings Triage tutorial](/user-guide/tutorials/prowler-app-findings-triage).
|
||||
</Note>
|
||||
|
||||
- **`prowler_cloud_list_finding_triages`** - List and filter persisted triage records by status, provider, check, and more
|
||||
- **`prowler_cloud_get_finding_triage`** - Retrieve a single finding's triage state by finding UID
|
||||
- **`prowler_cloud_set_finding_triage_status`** - Set a finding's triage status (`open`, `under_review`, `remediating`, `risk_accepted`, `false_positive`), optionally attaching a note. The `resolved` and `reopened` statuses are system-managed and cannot be set directly
|
||||
- **`prowler_cloud_list_finding_triage_notes`** - List the notes attached to a finding's triage, newest first
|
||||
- **`prowler_cloud_create_finding_triage_note`** - Add a new note to a finding's triage
|
||||
- **`prowler_cloud_update_finding_triage_note`** - Update the body of an existing note
|
||||
- **`prowler_cloud_delete_finding_triage_note`** - Delete a note from a finding's triage
|
||||
|
||||
### Scan Scheduling
|
||||
|
||||
Tools for configuring recurring scans. One schedule exists per provider, with daily, interval, weekly, or monthly frequency. These replace the Local-only `prowler_schedule_daily_scan`, which can only set up a daily scan. See the [Scan Scheduling tutorial](/user-guide/tutorials/prowler-scan-scheduling).
|
||||
|
||||
- **`prowler_cloud_list_scan_schedules`** - List scan schedules, one per visible provider
|
||||
- **`prowler_cloud_get_scan_schedule`** - Retrieve a provider's schedule including all per-frequency fields
|
||||
- **`prowler_cloud_set_scan_schedule`** - Configure or update a single provider's recurring scan schedule
|
||||
- **`prowler_cloud_bulk_set_scan_schedules`** - Apply one schedule to many providers at once
|
||||
- **`prowler_cloud_delete_scan_schedule`** - Delete a provider's scan schedule
|
||||
|
||||
### Alerts
|
||||
|
||||
Tools for notifying recipients when scan results match a rule condition. See the [Alerts tutorial](/user-guide/tutorials/prowler-alerts).
|
||||
|
||||
#### Alert Rules
|
||||
|
||||
- **`prowler_cloud_list_alert_rules`** - List and filter the custom alert rules defined in the tenant
|
||||
- **`prowler_cloud_get_alert_rule`** - Retrieve an alert rule including its condition DSL and recipient emails
|
||||
- **`prowler_cloud_create_alert_rule`** - Create a tenant-scoped alert rule
|
||||
- **`prowler_cloud_update_alert_rule`** - Update an alert rule; only the fields provided change
|
||||
- **`prowler_cloud_delete_alert_rule`** - Delete an alert rule
|
||||
- **`prowler_cloud_list_alert_rule_events`** - List the fired-alert history for a single rule, newest first
|
||||
- **`prowler_cloud_build_alert_rule_condition`** - Build a condition from a findings filter and dry-run it in one call to preview what would match. Nothing is persisted
|
||||
|
||||
#### Alert Recipients
|
||||
|
||||
- **`prowler_cloud_list_alert_recipients`** - List alert recipients with their confirmation status
|
||||
- **`prowler_cloud_get_alert_recipient`** - Retrieve a single recipient with its confirmation status
|
||||
- **`prowler_cloud_create_alert_recipient`** - Register a new recipient email
|
||||
- **`prowler_cloud_resend_alert_recipient_confirmation`** - Re-send the confirmation email to a pending or unsubscribed recipient
|
||||
- **`prowler_cloud_delete_alert_recipient`** - Delete an alert recipient
|
||||
|
||||
#### Alert Events
|
||||
|
||||
- **`prowler_cloud_list_alert_events`** - List the fired alert events for the tenant
|
||||
- **`prowler_cloud_get_alert_event`** - Retrieve a single alert event including its matched rule and scan
|
||||
|
||||
## Prowler Hub Tools
|
||||
|
||||
Access Prowler's security check catalog and compliance frameworks. **No authentication required.**
|
||||
@@ -162,7 +271,8 @@ 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 tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server)
|
||||
- Authentication is only required for the `prowler_*` and `prowler_cloud_*` tools; Prowler Hub and Prowler Documentation tools work without a key
|
||||
- If a `prowler_cloud_*` tool is missing from your client, you are connected to a Local MCP Server — point it at `https://mcp.prowler.com/mcp` instead
|
||||
|
||||
## Additional Resources
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ The console is available at [partners.prowler.com](https://partners.prowler.com)
|
||||
* **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.
|
||||
* **Branding:** upload your logo and preview its intended placement in Settings.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
@@ -35,7 +35,7 @@ Three objects make up the model. Getting these straight makes the rest of the do
|
||||
| **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 |
|
||||
| **Branding** | Prowler-branded | Logo upload and placement preview in Settings |
|
||||
|
||||
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.
|
||||
|
||||
@@ -46,7 +46,7 @@ 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 |
|
||||
| **Customers** | Review customer posture and billing and open customer tenants; Superadmins can also add customers | 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** |
|
||||
|
||||
@@ -54,7 +54,7 @@ Signing in lands you on the **Dashboard**. The sidebar carries:
|
||||
|
||||
**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.
|
||||
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, for Superadmins, carries its own **Add Customer** button.
|
||||
|
||||
## Getting Access
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowl
|
||||
</Card>
|
||||
|
||||
<Note>
|
||||
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).
|
||||
Prefer to run it yourself? The **Local MCP Server** runs on your own machine or infrastructure. The Cloud MCP Server additionally provides the `prowler_cloud_*` tools for Prowler Cloud-specific features: [Alerts](/user-guide/tutorials/prowler-alerts), [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and Scan Configurations. See [Cloud vs Local MCP Server](#cloud-vs-local-mcp-server).
|
||||
</Note>
|
||||
|
||||
## What is the Model Context Protocol?
|
||||
@@ -39,20 +39,30 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open s
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
The Prowler MCP Server provides three main integration points:
|
||||
The Prowler MCP Server provides four integration points:
|
||||
|
||||
### 1. Prowler Cloud, Private Cloud & Local Server
|
||||
|
||||
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
|
||||
- **Scan Orchestration**: Trigger on-demand scans, track their progress, and schedule a daily scan
|
||||
- **Resource Inventory**: Search and view detailed information about your audited resources
|
||||
- **Muting Management**: Create and manage muting lists/rules to suppress non-relevant findings
|
||||
- **Attack Paths Analysis**: Analyze privilege escalation chains and security misconfigurations through graph-based analysis of cloud resource relationships
|
||||
- **Integrations Management**: Set up and troubleshoot where Prowler sends its results (Amazon S3, AWS Security Hub, Jira), and turn findings into Jira work items
|
||||
- **User & Role Management**: List the users in your tenant, identify the authenticated user, browse RBAC roles, and set the role a user holds
|
||||
|
||||
### 2. Prowler Hub
|
||||
### 2. Prowler Cloud Management
|
||||
|
||||
Prowler Cloud-only tools for configuration and workflows that a Prowler Local Server does not provide. These are exposed only by the [Cloud MCP Server](#cloud-vs-local-mcp-server):
|
||||
|
||||
- **Scan Configurations**: Create reusable check and compliance selections and attach them to providers.
|
||||
- **Findings Triage**: Record review statuses and notes for individual findings, without suppressing them.
|
||||
- **Scan Scheduling**: Configure daily, interval, weekly, or monthly recurring scans, one provider at a time or in bulk.
|
||||
- **Alerts**: Build and dry-run alert rule conditions, manage email recipients, and review fired alerts.
|
||||
|
||||
### 3. Prowler Hub
|
||||
|
||||
Access to Prowler's comprehensive security knowledge base:
|
||||
- **Security Checks Catalog**: Browse and search **over 2,000 security checks** across multiple cloud providers.
|
||||
@@ -61,7 +71,7 @@ Access to Prowler's comprehensive security knowledge base:
|
||||
- **Compliance Frameworks**: Explore mappings to **over 70 compliance standards and frameworks**.
|
||||
- **Provider Services**: View available services and checks for each cloud provider.
|
||||
|
||||
### 3. Prowler Documentation
|
||||
### 4. Prowler Documentation
|
||||
|
||||
Search and retrieve official Prowler documentation:
|
||||
- **Intelligent Search**: Full-text search across all Prowler documentation.
|
||||
@@ -70,7 +80,7 @@ Search and retrieve official Prowler documentation:
|
||||
|
||||
## MCP Server Architecture
|
||||
|
||||
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:
|
||||
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 reach the same Prowler backends and share the `prowler_*`, `prowler_hub_*`, and `prowler_docs_*` tools; the Cloud MCP Server additionally exposes the Cloud-only `prowler_cloud_*` tools:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -82,12 +92,13 @@ flowchart LR
|
||||
|
||||
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"]
|
||||
cloud["Cloud MCP Server (Recommended)<br/>mcp.prowler.com/mcp · HTTP<br/>Managed by Prowler · always up to date<br/>Adds the Cloud-only prowler_cloud_* tools"]
|
||||
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"]
|
||||
prowler_tools["prowler_* tools<br/>(API key or JWT auth)<br/>Findings · Finding Groups · Providers<br/>Scans · Resources · Muting · Compliance<br/>Attack Paths · Integrations · Users · Roles"]
|
||||
cloud_tools["prowler_cloud_* tools<br/>(API key or JWT auth · Cloud only)<br/>Alerts · Findings Triage<br/>Scan Scheduling · Scan Configurations"]
|
||||
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
|
||||
@@ -104,6 +115,7 @@ flowchart LR
|
||||
apps -->|STDIO or HTTP| local
|
||||
|
||||
cloud --> prowler_tools
|
||||
cloud --> cloud_tools
|
||||
cloud --> hub_tools
|
||||
cloud --> docs_tools
|
||||
local --> prowler_tools
|
||||
@@ -111,12 +123,14 @@ flowchart LR
|
||||
local --> docs_tools
|
||||
|
||||
prowler_tools -->|REST| api
|
||||
cloud_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:
|
||||
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's four namespaced components:
|
||||
- Prowler Cloud, Prowler Private Cloud, or Prowler Local Server for security operations
|
||||
- Prowler Cloud management for Cloud-only configuration and workflows
|
||||
- Prowler Hub for security knowledge
|
||||
- Prowler Documentation for guidance and reference.
|
||||
|
||||
@@ -127,8 +141,15 @@ The Prowler MCP Server enables powerful workflows through AI assistants:
|
||||
**Security Operations**
|
||||
- "Show me all critical findings from my AWS production accounts"
|
||||
- "Register my new AWS account in Prowler and run a scheduled scan every day"
|
||||
- "List all muted findings and detect what findgings are muted by a not enough good reason in relation to their severity"
|
||||
- "List all muted findings and flag the ones whose mute reason is too weak for their severity"
|
||||
- "Run an attack paths query to find EC2 instances exposed to the Internet with access to sensitive S3 buckets"
|
||||
- "Send my failed CIS findings for this provider to Jira as work items"
|
||||
|
||||
**Prowler Cloud Management** (Cloud MCP Server only)
|
||||
- "Preview an alert rule for critical AWS findings and create it for my confirmed recipients"
|
||||
- "Show the triage notes for this finding and mark it as under review"
|
||||
- "Apply a weekly Monday 06:00 scan schedule to every AWS provider"
|
||||
- "Create a scan configuration that runs only CIS checks and attach it to my production providers"
|
||||
|
||||
**Security Research**
|
||||
- "Explain what the S3 bucket public access Prowler check does"
|
||||
@@ -199,17 +220,17 @@ There are two ways to run the Prowler MCP Server. For almost everyone, the **Clo
|
||||
|
||||
| | ☁️ **Cloud MCP Server** (Recommended) | 💻 **Local MCP Server** |
|
||||
|---|---|---|
|
||||
| **Cloud-only tools** (`prowler_cloud_*`) | ✅ Alerts, Findings Triage, Scan Scheduling, Scan Configurations | ❌ Not available |
|
||||
| **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) |
|
||||
|
||||
### ☁️ Cloud MCP Server (Recommended)
|
||||
|
||||
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).
|
||||
Prowler's managed MCP server at `https://mcp.prowler.com/mcp`. No installation, always up to date, and it includes the `prowler_cloud_*` tools for Prowler Cloud-specific features: Alerts, Findings Triage, Scan Scheduling, and Scan Configurations. 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).
|
||||
|
||||
### 💻 Local MCP Server
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 275 KiB |
@@ -7,12 +7,13 @@ flowchart LR
|
||||
|
||||
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"]
|
||||
cloud["Cloud MCP Server (Recommended)<br/>mcp.prowler.com/mcp · HTTP<br/>Managed by Prowler · always up to date<br/>Adds the Cloud-only prowler_cloud_* tools"]
|
||||
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"]
|
||||
prowler_tools["prowler_* tools<br/>(API key or JWT auth)<br/>Findings · Finding Groups · Providers<br/>Scans · Resources · Muting · Compliance<br/>Attack Paths · Integrations · Users · Roles"]
|
||||
cloud_tools["prowler_cloud_* tools<br/>(API key or JWT auth · Cloud only)<br/>Alerts · Findings Triage<br/>Scan Scheduling · Scan Configurations"]
|
||||
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
|
||||
@@ -29,6 +30,7 @@ flowchart LR
|
||||
apps -->|STDIO or HTTP| local
|
||||
|
||||
cloud --> prowler_tools
|
||||
cloud --> cloud_tools
|
||||
cloud --> hub_tools
|
||||
cloud --> docs_tools
|
||||
local --> prowler_tools
|
||||
@@ -36,5 +38,6 @@ flowchart LR
|
||||
local --> docs_tools
|
||||
|
||||
prowler_tools -->|REST| api
|
||||
cloud_tools -->|REST| api
|
||||
hub_tools -->|REST| hub
|
||||
docs_tools -->|REST| docs
|
||||
@@ -87,12 +87,14 @@ li[data-title="Prowler MCP"] > button span:first-child::after,
|
||||
li[data-title="Prowler for AI Agents"] > button span:first-child::after,
|
||||
div:has(+ ul a[href="/security/encryption"]) h3 span::after,
|
||||
li[id="/user-guide/compliance/tutorials/cross-provider-compliance"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/compliance/tutorials/cross-provider-type-compliance"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-feedback"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-import-findings"] a > div > div > span:first-child::after,
|
||||
|
||||
@@ -4,6 +4,8 @@ sidebarTitle: 'Overview'
|
||||
description: 'Run security checks against compliance frameworks, review posture across providers, and download CSV or PDF reports from Prowler Cloud and Prowler Local Server, or CSV reports from Prowler CLI.'
|
||||
---
|
||||
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
Prowler maps every security check to one or more industry-standard compliance frameworks, so a single scan produces both technical findings and framework-aligned evidence. The same evaluation runs identically whether scans are launched from Prowler Cloud, Prowler Local Server, or Prowler CLI.
|
||||
|
||||
Out of the box, Prowler covers frameworks such as CIS Benchmarks, NIST 800-53, NIST CSF, NIS2, ENS RD2022, ISO 27001, PCI-DSS, SOC 2, GDPR, HIPAA, AWS Well-Architected, BSI C5, CSA CCM, MITRE ATT&CK, KISA ISMS-P, FedRAMP, and Prowler ThreatScore. The full catalog is available at [Prowler Hub](https://hub.prowler.com/compliance).
|
||||
@@ -32,12 +34,17 @@ To open the compliance overview, follow these steps:
|
||||
1. Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) or to a Prowler Local Server instance.
|
||||
2. Select **Compliance** from the left navigation.
|
||||
|
||||
The page lists every framework evaluated by the most recent completed scan of the selected provider.
|
||||
The Compliance page is organized into two tabs:
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-overview.png" alt="Compliance overview page in Prowler Cloud and App showing filters, the Prowler ThreatScore card, and the framework grid" width="900" />
|
||||
* **Single Scan:** Lists every framework evaluated by one completed scan of one provider. This is the experience described in the rest of this guide, and the default view in Prowler Local Server.
|
||||
* **Multiple Scans:** Aggregates one framework across several scans at once, either across provider types ([Cross-Provider Type Compliance](/user-guide/compliance/tutorials/cross-provider-type-compliance)) or across every provider of the same type ([Cross-Provider Compliance](/user-guide/compliance/tutorials/cross-provider-compliance)).
|
||||
|
||||
<SubscriptionBanner label="Multiple Scans tab" />
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-overview.png" alt="Compliance overview page in Prowler Cloud and Prowler Local Server showing filters, the Prowler ThreatScore card, and the framework grid" width="900" />
|
||||
|
||||
<Note>
|
||||
Compliance results require at least one completed scan. If no scan has finished yet, Prowler Cloud and App display a notice prompting to launch or wait for a scan to complete.
|
||||
Compliance results require at least one completed scan. If no scan has finished yet, Prowler Cloud and Prowler Local Server display a notice prompting to launch or wait for a scan to complete.
|
||||
</Note>
|
||||
|
||||
### Filtering Compliance Results
|
||||
@@ -50,7 +57,7 @@ The scan selector lists completed scans across all connected providers. Each ent
|
||||
|
||||
#### Region Filter
|
||||
|
||||
The region multi-select narrows results to one or more regions detected in the selected scan. Use it to evaluate compliance posture for a specific geography or account boundary. The filter applies to:
|
||||
The region multi-select narrows results to one or more regions detected in the selected scan. Use it to evaluate compliance posture for a specific geography or regulatory boundary. The filter applies to:
|
||||
|
||||
* The framework grid scores and pass/fail counts.
|
||||
* The detailed requirement view inside each framework.
|
||||
@@ -145,7 +152,7 @@ Frameworks without a custom layout fall back to the generic details panel, which
|
||||
|
||||
### Downloading Compliance Reports
|
||||
|
||||
Prowler Cloud and App expose two formats:
|
||||
Prowler Cloud and Prowler Local Server expose two formats:
|
||||
|
||||
* **CSV report:** Every requirement, every check, and every finding for the selected scan and filters. Available for all supported frameworks.
|
||||
* **PDF report:** Curated executive-style report. Currently supported for Prowler ThreatScore, ENS RD2022, NIS2, and CSA CCM. Additional PDF reports are added in subsequent Prowler releases.
|
||||
@@ -183,7 +190,7 @@ Use the API to integrate compliance evidence into ticketing systems, executive d
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
Prowler CLI evaluates the same compliance frameworks as Prowler Cloud and App, and produces detailed CSV outputs alongside the standard scan results. By default, it runs every supported framework and prints a status summary at the end of the scan:
|
||||
Prowler CLI evaluates the same compliance frameworks as Prowler Cloud and Prowler Local Server, and produces detailed CSV outputs alongside the standard scan results. By default, it runs every supported framework and prints a status summary at the end of the scan:
|
||||
|
||||
<img src="/images/cli/compliance/compliance.png" />
|
||||
|
||||
@@ -263,6 +270,7 @@ To request a new framework or contribute one, see [Creating a New Security Compl
|
||||
|
||||
## Related Documentation
|
||||
|
||||
* [Cross-Provider Type Compliance](/user-guide/compliance/tutorials/cross-provider-type-compliance)
|
||||
* [Cross-Provider Compliance](/user-guide/compliance/tutorials/cross-provider-compliance)
|
||||
* [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore)
|
||||
* [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework)
|
||||
|
||||
@@ -1,48 +1,28 @@
|
||||
---
|
||||
title: 'Cross-Provider Compliance'
|
||||
sidebarTitle: 'Cross-Provider Compliance'
|
||||
description: 'Aggregate a universal compliance framework across compatible providers with completed scans, or a single-provider framework across every provider of one type, review consolidated roll-ups and per-provider breakdowns, and download combined PDF reports.'
|
||||
description: 'Aggregate a single-provider compliance framework across every provider of the same type, review the consolidated roll-up and per-provider coverage, and download a combined PDF report.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.34.0" />
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
Cross-Provider Compliance consolidates a single **universal compliance framework** across your compatible providers with completed scans into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and other supported providers as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF.
|
||||
Cross-Provider Compliance aggregates one **single-provider compliance framework** — CIS AWS, CIS GCP, ENS for Azure — across every provider of that type into a single view. It answers the question a per-scan report cannot: **"How compliant is my whole AWS estate against CIS AWS, together?"**
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
## What Is a Universal Compliance Framework
|
||||
|
||||
Most Prowler compliance frameworks target a single provider (for example, CIS AWS or CIS Azure). A **universal** framework declares its requirements once in a single JSON and maps each requirement to checks for many providers at the same time, so the same CSA CCM control maps to both AWS and Azure checks. Universal frameworks live at the top of the compliance catalog (`prowler/compliance/<framework>.json`), as opposed to the legacy per-provider frameworks under `prowler/compliance/<provider>/`. For real definitions, see [`csa_ccm_4.0.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/csa_ccm_4.0.json), [`cis_controls_8.1.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/cis_controls_8.1.json), and [`dora_2022_2554.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/dora_2022_2554.json) in the Prowler repository.
|
||||
|
||||
Prowler currently ships three universal frameworks: [CSA CCM](https://hub.prowler.com/compliance/csa_ccm_4.0), [CIS Controls](https://hub.prowler.com/compliance/cis_controls_8.1), and [DORA](https://hub.prowler.com/compliance/dora_2022_2554). Each framework page on Prowler Hub lists the full requirement-to-check mapping per provider.
|
||||
This view is the sibling of [Cross-Provider Type Compliance](/user-guide/compliance/tutorials/cross-provider-type-compliance), which aggregates a *universal* framework across different provider types. Both live in the **Multiple Scans** tab and share the same roll-up rules, scan selection, and report flow. Only the column axis changes, from provider type to individual provider.
|
||||
|
||||
## How Cross-Provider Compliance Works
|
||||
|
||||
Cross-Provider Compliance uses this structure to answer a single question: **"How compliant is my whole estate against this framework, regardless of provider?"** For a chosen universal framework, Prowler Cloud:
|
||||
For a chosen framework and provider type, Prowler Cloud:
|
||||
|
||||
1. Selects **one scan per compatible provider**: by default, the latest completed scan of each provider the framework supports and that you are allowed to see.
|
||||
2. Aggregates the requirement results across those scans, folding multiple accounts of the same provider type together.
|
||||
3. Computes a **roll-up status** for each requirement and an overall pass / fail / manual summary for the framework.
|
||||
4. Exposes a **per-provider breakdown** so you can see exactly which provider is failing a given control.
|
||||
|
||||
<Note>
|
||||
Cross-Provider Compliance never mixes different frameworks. It aggregates one universal framework at a time across providers. To review a single provider in isolation, use the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view.
|
||||
</Note>
|
||||
|
||||
### Supported Universal Frameworks
|
||||
|
||||
The Cross-Provider Compliance view currently supports the following universal frameworks. The compatible providers are the ones each framework declares checks for; a provider only contributes to the roll-up when it has a completed scan.
|
||||
|
||||
| Framework | Version | Compatible providers |
|
||||
|-----------|---------|----------------------|
|
||||
| [**CSA CCM**](https://hub.prowler.com/compliance/csa_ccm_4.0) (Cloud Controls Matrix) | 4.0 | AWS, Azure, Google Cloud, Alibaba Cloud, Oracle Cloud |
|
||||
| [**CIS Controls**](https://hub.prowler.com/compliance/cis_controls_8.1) | 8.1 | AWS, Azure, Google Cloud, Microsoft 365, Kubernetes, GitHub, Google Workspace, Okta, Oracle Cloud, Alibaba Cloud, Cloudflare, MongoDB Atlas, OpenStack, Vercel |
|
||||
| [**DORA**](https://hub.prowler.com/compliance/dora_2022_2554) (Digital Operational Resilience Act) | 2022/2554 | AWS, Azure, Google Cloud, Alibaba Cloud, Cloudflare |
|
||||
|
||||
The catalog grows as new universal frameworks ship in Prowler. Browse the full compliance catalog at [Prowler Hub](https://hub.prowler.com/compliance).
|
||||
1. Selects **one scan per provider**: the latest completed scan of every provider of that type you are allowed to see.
|
||||
2. Aggregates the requirement results across those scans.
|
||||
3. Computes a **roll-up status** for each requirement and an overall pass / fail / manual summary.
|
||||
4. Exposes a **per-provider breakdown** so a failing provider is immediately attributable.
|
||||
|
||||
## Accessing the Cross-Provider View
|
||||
|
||||
@@ -50,86 +30,76 @@ The catalog grows as new universal frameworks ship in Prowler. Browse the full c
|
||||
<Step title="Open the Compliance section">
|
||||
Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) and select **Compliance** from the left navigation.
|
||||
</Step>
|
||||
<Step title="Switch to the Multiple Scans tab">
|
||||
At the top of the Compliance page, select the **Multiple Scans** tab. The **Single Scan** tab (the default) keeps the single-provider compliance experience unchanged.
|
||||
<Step title="Open the Multiple Scans tab">
|
||||
**Multiple Scans** is the landing tab of the Compliance page in Prowler Cloud. Scroll to the **Across providers** section, below the **Across provider types** cards.
|
||||
</Step>
|
||||
<Step title="Expand a provider type">
|
||||
Each provider type is a collapsible group headed by its counts (frameworks available and providers registered). Expanding it reveals one card per single-provider framework available for that type.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-tab.png" alt="Compliance page showing the Single Scan and Multiple Scans tabs, with the Multiple Scans tab highlighted" width="900" />
|
||||
<img src="/images/compliance/prowler-app-compliance-multiple-scans.png" alt="Multiple Scans tab showing the Across provider types cards and the Across providers section, with the AWS group collapsed and its framework and provider counts" width="900" />
|
||||
|
||||
<Note>
|
||||
The **Across provider types** section requires at least one completed scan for a provider compatible with a universal framework. If none is available, that section shows a notice prompting you to launch or wait for a scan to complete. The **Across providers** section can still list single-provider frameworks when its own account and scan requirements are met.
|
||||
A provider type appears only when it has two or more providers registered **and** at least one of them has a completed scan: that scan is where the framework catalog of the provider type is read from. With a single provider the aggregation is identical to the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view.
|
||||
</Note>
|
||||
|
||||
## Exploring the Overview
|
||||
Framework cards in this section carry no score: they enumerate which frameworks can be aggregated for a provider type, and the roll-up numbers are computed on the detail page. Two catalogs stay out of the section, because each already has its own view:
|
||||
|
||||
The Multiple Scans tab is organized into two sections, each labeled with the axis it aggregates across:
|
||||
* **Universal frameworks:** Aggregated in the **Across provider types** section above. See [Cross-Provider Type Compliance](/user-guide/compliance/tutorials/cross-provider-type-compliance).
|
||||
* **Prowler ThreatScore:** Reviewed per scan in the **Single Scan** tab. See [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore).
|
||||
|
||||
* **Across provider types:** One card per supported universal framework, aggregating every compatible provider type. This is the Cross-Provider Compliance experience described in the rest of this guide.
|
||||
* **Across providers:** One card per single-provider framework (for example, CIS AWS) that can be aggregated across every connected provider of the same type. See [Aggregating a Single-Provider Framework Across Providers](#aggregating-a-single-provider-framework-across-providers).
|
||||
<img src="/images/compliance/prowler-app-across-providers-expanded.png" alt="Across providers section with the AWS group expanded, showing one card per single-provider framework with its View across providers link and provider count" width="900" />
|
||||
|
||||
The **Across provider types** section presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider.
|
||||
## Which Providers Are Listed and Which Contribute
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-overview.png" alt="Cross-Provider Compliance overview showing the provider filters and the framework grid (CSA CCM, CIS Controls, DORA) with per-provider chips, scores, and failed/manual counts" width="900" />
|
||||
The **Across providers** section and the detail page count different things, so their numbers often differ:
|
||||
|
||||
Each **framework card** includes:
|
||||
* The section describes your **catalog**: the group header and each framework card report how many providers of that type exist in Prowler Cloud, after the filters you applied.
|
||||
* The detail page describes your **evidence**: only providers with a completed scan become a column in the aggregation, because every number on that page is computed from scan results.
|
||||
|
||||
* **Framework logo, name, and version:** Identifies the universal standard (CSA CCM, CIS Controls, DORA).
|
||||
* **Score:** The percentage of passing requirements over the total evaluated, aggregated across every contributing provider. Color coding follows three thresholds: red for severely low compliance, amber for partial compliance, and green for healthy posture.
|
||||
* **Passing Requirements:** A `passed / total` counter with the aggregated roll-up.
|
||||
* **Provider chips:** One icon per compatible provider. Providers with a completed scan appear active with their passing percentage; providers without a scan appear dimmed with a "no completed scan yet" tooltip so coverage gaps are obvious at a glance.
|
||||
* **Failed and manual counts:** The number of failing and manual requirements in the roll-up.
|
||||
A card can therefore read `17 providers` while its detail page reports two providers aggregated from two scans. The other 15 providers exist in Prowler Cloud but have no completed scan, so there is nothing of theirs to aggregate. This is the expected state right after onboarding an AWS Organization: the discovery wizard registers every member it finds in the organization as a provider, and providers not scanned yet count toward the catalog while contributing nothing to the roll-up. See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) for that onboarding flow.
|
||||
|
||||
Select any card to open the framework detail page.
|
||||
What decides whether a provider contributes is **having a completed scan**, not its connection status:
|
||||
|
||||
### Filtering the Roll-Up
|
||||
* **Completed scans only:** Failed, cancelled, and in-progress scans are ignored, so a provider whose latest scan is still running keeps contributing its previous completed one.
|
||||
* **Disconnected providers still count:** A provider whose credentials stopped working contributes its last completed scan. The posture it shows is as old as that scan.
|
||||
* **Newly connected providers do not:** A provider contributes nothing until its first scan completes.
|
||||
|
||||
The filters bar controls which providers and accounts feed every card and detail view. Cross-Provider Compliance supports three filters:
|
||||
To confirm which providers made it into an aggregation, read the coverage summary in the detail page header and the coverage card, which lists one row per contributing provider.
|
||||
|
||||
* **Provider type:** Narrow the roll-up to specific provider types (for example, only AWS and Azure).
|
||||
* **Provider / account:** Narrow to specific connected accounts.
|
||||
* **Provider group:** Narrow to accounts within one or more provider groups.
|
||||
|
||||
Select **Clear filters** to reset all filters. Filters applied on the overview are carried through into the detail page and the PDF report so the view stays consistent end to end.
|
||||
|
||||
<Note>
|
||||
Filters narrow **which providers contribute** to the aggregation. They do not change how a requirement rolls up (see [Understanding the Roll-Up Status](#understanding-the-roll-up-status)).
|
||||
</Note>
|
||||
<Warning>
|
||||
The **Providers** filter on the detail page lists every provider of the type, including ones that have never been scanned. Narrowing to providers with no completed scan leaves the view with no evidence to aggregate: the coverage card reports nothing scanned, requirements show no per-provider status, and any report generated for that selection is empty. Clear the filter or select providers that have already been scanned.
|
||||
</Warning>
|
||||
|
||||
## Working With the Framework Detail Page
|
||||
|
||||
The detail page provides the full breakdown for a single universal framework: aggregate metrics, provider coverage, top failing sections, and a requirement-by-requirement view with per-provider status.
|
||||
Selecting a card opens a detail page with the same layout as the cross-provider-type detail, with the column axis swapped from provider type to provider:
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-detail.png" alt="Cross-Provider Compliance detail page for CSA CCM 4.0 showing the header with providers scanned and the Report button, the filters, and the Requirements Status, Provider Coverage, and Top Failed Sections summary cards" width="900" />
|
||||
* **Header:** States the framework, the provider type, and the real coverage behind the numbers, as a count of aggregated providers and scans. The **Report** button generates the combined PDF.
|
||||
* **Requirements Status:** Donut chart with the consolidated `Pass`, `Fail`, and `Manual` counts.
|
||||
* **Coverage card:** Ranks each contributing provider's individual posture, so the weakest one is visible at a glance. Every row is one provider of the type, labeled with its alias and unique identifier (UID).
|
||||
* **Top Failed Sections:** Ranks the framework sections with the most failing requirements, with deep links into the requirements accordion.
|
||||
* **Requirements accordion:** Each requirement shows its roll-up badge plus the status of every contributing provider, labeled with the provider alias and unique identifier (UID). With one or two providers the statuses appear as inline chips; from three onwards the row condenses into per-status counts (for example, `Fail ×3 Pass ×6`), and selecting the counts opens the full provider-by-provider breakdown. Expanding a requirement queries the findings of every contributing scan and merges them into a single table.
|
||||
|
||||
### Header
|
||||
<img src="/images/compliance/prowler-app-across-providers-detail.png" alt="Cross-Provider Compliance detail page for CIS AWS 7.0 showing the header with the aggregated providers and scans, the Report button, the Providers and Provider group filters, and the Requirements Status, coverage, and Top Failed Sections cards above the requirements accordion" width="900" />
|
||||
|
||||
The header shows the framework name and version, a link to the framework page on [Prowler Hub](https://hub.prowler.com/compliance), and a summary such as *"X of Y compatible providers scanned · N scans aggregated"* so you always know the coverage behind the numbers. The **Report** button in the top-right generates and downloads the combined PDF (see [Downloading the Combined PDF Report](#downloading-the-combined-pdf-report)).
|
||||
<Note>
|
||||
Checks are not labeled per provider type as in the cross-provider-type view. Every provider of the same type shares one check set, so a single list of checks covers the whole aggregation.
|
||||
</Note>
|
||||
|
||||
### Summary Cards
|
||||
### Filtering the Roll-Up
|
||||
|
||||
Below the header, three summary cards condense the framework state:
|
||||
Two filters control which providers feed the aggregation:
|
||||
|
||||
* **Requirements Status:** Donut chart with `Pass`, `Fail`, and `Manual` counts plus the total number of requirements, reflecting the consolidated roll-up.
|
||||
* **Provider Coverage:** Shows which compatible providers contributed a scan and their individual posture, so coverage gaps and per-provider weak spots are visible at a glance.
|
||||
* **Top Failed Sections:** Ranks the framework sections with the highest number of failing requirements, with deep links into the requirements accordion.
|
||||
* **Providers:** Narrow to specific providers of the type, listed by alias and UID.
|
||||
* **Provider group:** Narrow to the providers belonging to one or more provider groups.
|
||||
|
||||
### Requirements Accordion
|
||||
|
||||
The accordion organizes every requirement of the framework. For each requirement you see:
|
||||
|
||||
* **Requirement ID and title:** The official identifier from the framework.
|
||||
* **Roll-up status badge:** A single `Pass`, `Fail`, or `Manual` badge representing the consolidated status across all contributing providers.
|
||||
* **Per-provider status:** The status each contributing provider returned for that requirement, so a single failing provider is immediately attributable.
|
||||
* **Provider-labeled checks:** When you expand a requirement, the underlying checks are labeled with the provider they belong to (each universal requirement maps to different check IDs per provider).
|
||||
|
||||
Expand a requirement to review the failing checks per provider, the affected resources, and remediation guidance. Findings are queried across every contributing scan and merged into a single table.
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-requirements-accordion.png" alt="Expanded DORA requirement showing the per-provider Fail badges for AWS, Azure, and Google Cloud, the requirement description and attributes, the checks count, and the merged findings table with a Provider column" width="900" />
|
||||
The provider type is fixed by the framework, so there is no type filter here. Filters applied on the overview carry through into the detail page and the PDF report.
|
||||
|
||||
## Understanding the Roll-Up Status
|
||||
|
||||
Cross-Provider Compliance rolls up results in two stages, with a strict **FAIL > PASS > MANUAL** precedence.
|
||||
Results roll up in two stages, with a strict **FAIL > PASS > MANUAL** precedence.
|
||||
|
||||
**Per provider, per requirement:**
|
||||
|
||||
@@ -137,8 +107,6 @@ Cross-Provider Compliance rolls up results in two stages, with a strict **FAIL >
|
||||
* Else if every check passes → **PASS**.
|
||||
* Otherwise (no pass/fail evidence) → **MANUAL**.
|
||||
|
||||
Multiple accounts of the same provider type (for example, three AWS accounts) are folded together first, so a failure in any one account marks that provider type as failing.
|
||||
|
||||
**Across providers, per requirement (the roll-up badge):**
|
||||
|
||||
* If at least one contributing provider is **FAIL** → the requirement is **FAIL**.
|
||||
@@ -146,90 +114,26 @@ Multiple accounts of the same provider type (for example, three AWS accounts) ar
|
||||
* Otherwise → **MANUAL**.
|
||||
|
||||
<Note>
|
||||
Only providers that **actually contributed a result** for a requirement are counted. A provider that has a scan in the aggregation but produced no result for a specific requirement (for example, because the framework maps no checks to that provider for that control) does **not** degrade the requirement to Manual. This keeps the roll-up focused on real evidence.
|
||||
Only providers that **actually contributed a result** for a requirement are counted. A provider whose scan produced no result for a specific requirement does not degrade that requirement to Manual, which keeps the roll-up focused on real evidence.
|
||||
</Note>
|
||||
|
||||
### How Scans Are Selected
|
||||
|
||||
By default, Cross-Provider Compliance auto-selects the **latest completed scan** of each compatible provider you are allowed to see. This means:
|
||||
|
||||
* The view always reflects your most recent posture per provider, without any manual scan selection.
|
||||
* Adding a new compatible provider and running a scan automatically brings it into the roll-up.
|
||||
* Provider visibility follows your role: you only ever see providers your permissions allow, and the roll-up is scoped accordingly.
|
||||
Scan selection and permission scoping match the cross-provider-type view: the aggregation always reflects each provider's most recent completed scan, restricted to the providers your role is allowed to see. To review how provider visibility is granted, see [Role-Based Access Control (RBAC)](/user-guide/tutorials/prowler-app-rbac).
|
||||
|
||||
## Downloading the Combined PDF Report
|
||||
|
||||
The **Report** button on the detail page generates a single PDF that combines every contributing provider's latest scan for the framework into one executive document: a cover page listing all providers and accounts, an executive summary with the consolidated roll-up, charts, a requirements index, and detailed findings grouped by requirement, provider, and account.
|
||||
The **Report** button produces a single PDF across every contributing provider of the type: a cover page listing the providers, an executive summary with the consolidated roll-up, charts, a requirements index, and detailed findings grouped by requirement and provider.
|
||||
|
||||
### The Report Follows Your Filters
|
||||
<img src="/images/compliance/prowler-app-across-providers-report.png" alt="Report dropdown on the Cross-Provider Compliance detail page showing the Generate new report option" width="420" />
|
||||
|
||||
A report covers the exact set of scans your current filters resolve to. The provider type, provider / account, and provider group filters applied on the detail page determine which providers contribute, and the auto-select rule pins each contributing provider's latest completed scan. The PDF is built from that resolved scan set together with the framework.
|
||||
|
||||
As a result, each filter combination produces its own report. For example:
|
||||
|
||||
* No filters → a report covering every compatible provider that has a completed scan.
|
||||
* `Provider type = AWS, Azure` → a report covering only your AWS and Azure scans.
|
||||
* `Provider group = Production` → a report covering only the accounts in that group.
|
||||
|
||||
Changing the filters and generating again produces a different, independent report. Each combination is tracked on its own, so switching filters back and forth never overwrites a previously generated report.
|
||||
Generation is asynchronous and behaves exactly like the cross-provider-type report — background job, toast notification when ready, and **Report → Download latest** to reuse a report already generated for the current filters. See [Downloading the Combined PDF Report](/user-guide/compliance/tutorials/cross-provider-type-compliance#downloading-the-combined-pdf-report) for the full flow, including report reuse and the findings cap.
|
||||
|
||||
<Note>
|
||||
Region filtering is **not** supported for the combined PDF report. The report recomputes status live across every region of the contributing scans, so a region-scoped request is rejected rather than producing a report that contradicts a region-filtered view.
|
||||
A report is tied to the exact set of scans it was built from. When any contributing provider completes a new scan, the previous report no longer matches the current selection and Prowler Cloud offers to generate an up-to-date one.
|
||||
</Note>
|
||||
|
||||
### Generating and Reusing a Report
|
||||
|
||||
Because the report aggregates many scans, it is generated **asynchronously**:
|
||||
|
||||
<Steps>
|
||||
<Step title="Start the report">
|
||||
Select **Report → Generate new report…**, optionally give it a name, and confirm. Prowler starts a background job and shows a "Report generation started" confirmation.
|
||||
</Step>
|
||||
<Step title="Wait for completion">
|
||||
The button shows a "Generating report…" state while the job runs. Generation continues in the background: you can navigate away, and a toast notification appears when the report is ready, even after a page reload.
|
||||
</Step>
|
||||
<Step title="Download">
|
||||
When the report is ready, select **Download** from the notification, or use **Report → Download latest** at any time to fetch the most recent report for the current filters.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-report.png" alt="Report dropdown on the Cross-Provider Compliance detail page showing the Generate new report option" width="420" />
|
||||
|
||||
A report already generated for a given set of filters does not need to be generated again. When you open the detail page with a filter combination that was reported before, Prowler detects the existing report and surfaces **Report → Download latest** so you can download it immediately, without launching a new job. You only need to generate a fresh report when:
|
||||
|
||||
* You apply a filter combination that has never been reported before, or
|
||||
* A contributing provider has completed a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer completed scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one.
|
||||
|
||||
"Download latest" reuses an existing report only when it matches the framework, the exact resolved scan set for the current filters, and the same report options. This guarantees the PDF you download reflects the posture you are looking at, rather than a report generated for a different filter or an older scan.
|
||||
|
||||
<Note>
|
||||
The PDF detail section renders only **failed** requirements by default so the report stays focused as an executive/auditor document. As with every Prowler PDF, the detail section is capped at the first 100 failed findings per check; use the per-scan CSV or JSON-OCSF exports for the complete, untruncated list. See [Downloading Compliance Reports](/user-guide/compliance/tutorials/compliance#downloading-compliance-reports) for the full PDF behavior and the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` setting.
|
||||
</Note>
|
||||
|
||||
## Aggregating a Single-Provider Framework Across Providers
|
||||
|
||||
Universal frameworks answer the cross-provider-type question, but most compliance frameworks target a single provider type — CIS AWS, CIS GCP, ENS for Azure. The **Across providers** section of the Multiple Scans tab answers the sibling question for those frameworks: **"How compliant are all of my AWS accounts against CIS AWS, together?"**
|
||||
|
||||
For every provider type with **two or more connected providers**, the section shows a collapsible group headed by the provider type and its counts (frameworks available and providers connected). Expanding a group reveals one card per single-provider framework available for that type, keeping the section compact even when several multi-provider types are connected. Selecting a card opens a detail page with the same layout as the cross-provider detail, with the column axis swapped from provider type to provider:
|
||||
|
||||
* **One column per provider:** Prowler auto-selects the latest completed scan of every provider of that type you are allowed to see, and each requirement shows a status chip per provider (labeled with its alias or account ID).
|
||||
* **Account Coverage:** The coverage card ranks each provider's individual posture so the weakest account is visible at a glance.
|
||||
* **Filters:** Narrow the aggregation to specific providers or provider groups. The provider type is fixed by the framework, so there is no type filter here.
|
||||
* **Findings drill-down:** Expanding a requirement queries the findings of every contributing provider's scan and merges them into a single table.
|
||||
|
||||
The roll-up rules are identical to the cross-provider ones (**FAIL > PASS > MANUAL**, only providers that contributed a result count), applied per provider instead of per provider type. Scan selection, RBAC scoping, and staleness behavior also match: the view always reflects each provider's most recent completed scan, scoped to your role's visibility.
|
||||
|
||||
<Note>
|
||||
Provider types with a single connected provider are not listed — with one provider the aggregation is identical to the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view.
|
||||
</Note>
|
||||
|
||||
### Cross-Account PDF Report
|
||||
|
||||
The detail page's **Report** button produces a single combined PDF across every contributing provider of the type, with the same asynchronous generation, background notification, and **Download latest** reuse flow as the [cross-provider report](#downloading-the-combined-pdf-report). The report is tied to the exact resolved scan set, so a new completed scan in any contributing provider makes the previous report stale and Prowler offers to generate an up-to-date one.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
* [Cross-Provider Type Compliance](/user-guide/compliance/tutorials/cross-provider-type-compliance)
|
||||
* [Compliance](/user-guide/compliance/tutorials/compliance)
|
||||
* [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations)
|
||||
* [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore)
|
||||
* [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework)
|
||||
* [Prowler Cloud — Getting Started](/user-guide/tutorials/prowler-app)
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
title: 'Cross-Provider Type Compliance'
|
||||
sidebarTitle: 'Cross-Provider Type Compliance'
|
||||
description: 'Aggregate a universal compliance framework across every compatible provider with a completed scan, review the consolidated roll-up and per-provider breakdown, and download a combined PDF report.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.34.0" />
|
||||
|
||||
Cross-Provider Type Compliance consolidates a single **universal compliance framework** across your compatible providers with completed scans into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and other supported providers as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF.
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
## What Is a Universal Compliance Framework
|
||||
|
||||
Most Prowler compliance frameworks target a single provider (for example, CIS AWS or CIS Azure). A **universal** framework declares its requirements once in a single JSON and maps each requirement to checks for many providers at the same time, so the same CSA CCM control maps to both AWS and Azure checks. Universal frameworks live at the top of the compliance catalog (`prowler/compliance/<framework>.json`), as opposed to the legacy per-provider frameworks under `prowler/compliance/<provider>/`. For real definitions, see [`csa_ccm_4.0.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/csa_ccm_4.0.json), [`cis_controls_8.1.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/cis_controls_8.1.json), and [`dora_2022_2554.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/dora_2022_2554.json) in the Prowler repository.
|
||||
|
||||
Prowler currently ships three universal frameworks: [CSA CCM](https://hub.prowler.com/compliance/csa_ccm_4.0), [CIS Controls](https://hub.prowler.com/compliance/cis_controls_8.1), and [DORA](https://hub.prowler.com/compliance/dora_2022_2554). Each framework page on Prowler Hub lists the full requirement-to-check mapping per provider.
|
||||
|
||||
## How Cross-Provider Type Compliance Works
|
||||
|
||||
Cross-Provider Type Compliance uses this structure to answer a single question: **"How compliant is my whole estate against this framework, regardless of provider?"** For a chosen universal framework, Prowler Cloud:
|
||||
|
||||
1. Selects **one scan per compatible provider**: by default, the latest completed scan of each provider the framework supports and that you are allowed to see.
|
||||
2. Aggregates the requirement results across those scans, folding multiple providers of the same type together.
|
||||
3. Computes a **roll-up status** for each requirement and an overall pass / fail / manual summary for the framework.
|
||||
4. Exposes a **per-provider breakdown** so you can see exactly which provider is failing a given control.
|
||||
|
||||
<Note>
|
||||
Cross-Provider Type Compliance never mixes different frameworks. It aggregates one universal framework at a time across providers. To review a single provider in isolation, use the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view.
|
||||
</Note>
|
||||
|
||||
### Supported Universal Frameworks
|
||||
|
||||
The Cross-Provider Type Compliance view currently supports the following universal frameworks. The compatible providers are the ones each framework declares checks for; a provider only contributes to the roll-up when it has a completed scan.
|
||||
|
||||
| Framework | Version | Compatible providers |
|
||||
|-----------|---------|----------------------|
|
||||
| [**CSA CCM**](https://hub.prowler.com/compliance/csa_ccm_4.0) (Cloud Controls Matrix) | 4.0 | AWS, Azure, Google Cloud, Alibaba Cloud, Oracle Cloud |
|
||||
| [**CIS Controls**](https://hub.prowler.com/compliance/cis_controls_8.1) | 8.1 | AWS, Azure, Google Cloud, Microsoft 365, Kubernetes, GitHub, Google Workspace, Okta, Oracle Cloud, Alibaba Cloud, Cloudflare, MongoDB Atlas, OpenStack, Vercel |
|
||||
| [**DORA**](https://hub.prowler.com/compliance/dora_2022_2554) (Digital Operational Resilience Act) | 2022/2554 | AWS, Azure, Google Cloud, Alibaba Cloud, Cloudflare |
|
||||
|
||||
The catalog grows as new universal frameworks ship in Prowler. Browse the full compliance catalog at [Prowler Hub](https://hub.prowler.com/compliance).
|
||||
|
||||
## Accessing the Cross-Provider Type View
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the Compliance section">
|
||||
Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) and select **Compliance** from the left navigation.
|
||||
</Step>
|
||||
<Step title="Open the Multiple Scans tab">
|
||||
In Prowler Cloud, **Multiple Scans** is the landing tab of the Compliance page, so it opens already selected. Select **Single Scan** at the top of the page to return to the per-scan compliance experience, which remains unchanged.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
The **Across provider types** section requires at least one completed scan for a provider compatible with a universal framework. If none is available, that section shows a notice prompting you to launch or wait for a scan to complete. The **Across providers** section below it has its own requirements — see [Cross-Provider Compliance](/user-guide/compliance/tutorials/cross-provider-compliance).
|
||||
</Note>
|
||||
|
||||
## Exploring the Overview
|
||||
|
||||
The Multiple Scans tab is organized into two sections, each labeled with the axis it aggregates across:
|
||||
|
||||
* **Across provider types:** One card per supported universal framework, aggregating every compatible provider type. This is the Cross-Provider Type Compliance experience described in the rest of this guide.
|
||||
* **Across providers:** One card per single-provider framework (for example, CIS AWS) that can be aggregated across every provider of the same type. See [Cross-Provider Compliance](/user-guide/compliance/tutorials/cross-provider-compliance).
|
||||
|
||||
The **Across provider types** section presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider.
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-multiple-scans.png" alt="Multiple Scans tab showing the provider filters, the Across provider types grid (CSA CCM, CIS Controls, DORA) with per-provider chips, scores, and failed and manual counts, and the Across providers section below it" width="900" />
|
||||
|
||||
Each **framework card** includes:
|
||||
|
||||
* **Framework logo, name, and version:** Identifies the universal standard (CSA CCM, CIS Controls, DORA).
|
||||
* **Score:** The percentage of passing requirements over the total evaluated, aggregated across every contributing provider. Color coding follows three thresholds: red for severely low compliance, amber for partial compliance, and green for healthy posture.
|
||||
* **Passing Requirements:** A `passed / total` counter with the aggregated roll-up.
|
||||
* **Provider chips:** One icon per compatible provider. Providers with a completed scan appear active with their passing percentage; providers without a scan appear dimmed with a "no completed scan yet" tooltip so coverage gaps are obvious at a glance.
|
||||
* **Failed and manual counts:** The number of failing and manual requirements in the roll-up.
|
||||
|
||||
Select any card to open the framework detail page.
|
||||
|
||||
### Filtering the Roll-Up
|
||||
|
||||
The filters bar controls which providers feed every card and detail view. Cross-Provider Type Compliance supports three filters:
|
||||
|
||||
* **Provider type:** Narrow the roll-up to specific provider types (for example, only AWS and Azure).
|
||||
* **Providers:** Narrow to specific providers, listed by alias and unique identifier (UID).
|
||||
* **Provider group:** Narrow to the providers belonging to one or more provider groups.
|
||||
|
||||
Select **Clear filters** to reset all filters. Filters applied on the overview are carried through into the detail page and the PDF report so the view stays consistent end to end.
|
||||
|
||||
<Note>
|
||||
Filters narrow **which providers contribute** to the aggregation. They do not change how a requirement rolls up (see [Understanding the Roll-Up Status](#understanding-the-roll-up-status)).
|
||||
</Note>
|
||||
|
||||
## Working With the Framework Detail Page
|
||||
|
||||
The detail page provides the full breakdown for a single universal framework: aggregate metrics, provider coverage, top failing sections, and a requirement-by-requirement view with per-provider status.
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-detail.png" alt="Cross-Provider Type Compliance detail page for CSA CCM 4.0 showing the header with providers scanned and the Report button, the filters, and the Requirements Status, Provider Coverage, and Top Failed Sections summary cards" width="900" />
|
||||
|
||||
### Header
|
||||
|
||||
The header shows the framework name and version, a link to the framework page on [Prowler Hub](https://hub.prowler.com/compliance), and a summary such as *"X of Y compatible providers scanned · N scans aggregated"* so you always know the coverage behind the numbers. The **Report** button in the top-right generates and downloads the combined PDF (see [Downloading the Combined PDF Report](#downloading-the-combined-pdf-report)).
|
||||
|
||||
### Summary Cards
|
||||
|
||||
Below the header, three summary cards condense the framework state:
|
||||
|
||||
* **Requirements Status:** Donut chart with `Pass`, `Fail`, and `Manual` counts plus the total number of requirements, reflecting the consolidated roll-up.
|
||||
* **Provider Coverage:** Shows which compatible providers contributed a scan and their individual posture, so coverage gaps and per-provider weak spots are visible at a glance.
|
||||
* **Top Failed Sections:** Ranks the framework sections with the highest number of failing requirements, with deep links into the requirements accordion.
|
||||
|
||||
### Requirements Accordion
|
||||
|
||||
The accordion organizes every requirement of the framework. For each requirement you see:
|
||||
|
||||
* **Requirement ID and title:** The official identifier from the framework.
|
||||
* **Roll-up status badge:** A single `Pass`, `Fail`, or `Manual` badge representing the consolidated status across all contributing providers.
|
||||
* **Per-provider status:** The status each contributing provider returned for that requirement, so a single failing provider is immediately attributable. Up to five providers are shown as inline chips; beyond that the row condenses into per-status counts (for example, `Fail ×3 Pass ×6`), and selecting the counts opens the full provider-by-provider breakdown.
|
||||
* **Provider-labeled checks:** When you expand a requirement, the underlying checks are labeled with the provider they belong to (each universal requirement maps to different check IDs per provider).
|
||||
|
||||
Expand a requirement to review the failing checks per provider, the affected resources, and remediation guidance. Findings are queried across every contributing scan and merged into a single table.
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-requirements-accordion.png" alt="Expanded DORA requirement showing the per-provider Fail badges for AWS, Azure, and Google Cloud, the requirement description and attributes, the checks count, and the merged findings table with a Provider column" width="900" />
|
||||
|
||||
## Understanding the Roll-Up Status
|
||||
|
||||
Cross-Provider Type Compliance rolls up results in two stages, with a strict **FAIL > PASS > MANUAL** precedence.
|
||||
|
||||
**Per provider, per requirement:**
|
||||
|
||||
* If any check fails → the provider contributes **FAIL** for that requirement.
|
||||
* Else if every check passes → **PASS**.
|
||||
* Otherwise (no pass/fail evidence) → **MANUAL**.
|
||||
|
||||
Multiple providers of the same type (for example, three AWS providers) are folded together first, so a failure in any one of them marks that provider type as failing.
|
||||
|
||||
**Across providers, per requirement (the roll-up badge):**
|
||||
|
||||
* If at least one contributing provider is **FAIL** → the requirement is **FAIL**.
|
||||
* Else if at least one contributing provider is **PASS** → **PASS**.
|
||||
* Otherwise → **MANUAL**.
|
||||
|
||||
<Note>
|
||||
Only providers that **actually contributed a result** for a requirement are counted. A provider that has a scan in the aggregation but produced no result for a specific requirement (for example, because the framework maps no checks to that provider for that control) does **not** degrade the requirement to Manual. This keeps the roll-up focused on real evidence.
|
||||
</Note>
|
||||
|
||||
### How Scans Are Selected
|
||||
|
||||
By default, Cross-Provider Type Compliance auto-selects the **latest completed scan** of each compatible provider you are allowed to see. This means:
|
||||
|
||||
* The view always reflects your most recent posture per provider, without any manual scan selection.
|
||||
* Adding a new compatible provider and running a scan automatically brings it into the roll-up.
|
||||
* Provider visibility follows your role: only the providers your permissions allow are ever shown, and the roll-up is scoped accordingly. See [Role-Based Access Control (RBAC)](/user-guide/tutorials/prowler-app-rbac).
|
||||
|
||||
What makes a provider contribute is **having a completed scan**, not its current connection status:
|
||||
|
||||
* Only scans in the **completed** state are eligible. Failed, cancelled, and in-progress scans are ignored, so a provider whose latest scan is still running keeps contributing its previous completed one.
|
||||
* A provider whose credentials stopped working still contributes its last completed scan. The posture it shows is as old as that scan.
|
||||
* A provider connected but never scanned contributes nothing until its first scan completes. On the framework cards it appears dimmed instead of disappearing, so the coverage gap stays visible.
|
||||
|
||||
## Downloading the Combined PDF Report
|
||||
|
||||
The **Report** button on the detail page generates a single PDF that combines every contributing provider's latest scan for the framework into one executive document: a cover page listing every contributing provider, an executive summary with the consolidated roll-up, charts, a requirements index, and detailed findings grouped by requirement and provider.
|
||||
|
||||
### The Report Follows Your Filters
|
||||
|
||||
A report covers the exact set of scans your current filters resolve to. The provider type, providers, and provider group filters applied on the detail page determine which providers contribute, and the auto-select rule pins each contributing provider's latest completed scan. The PDF is built from that resolved scan set together with the framework.
|
||||
|
||||
As a result, each filter combination produces its own report. For example:
|
||||
|
||||
* No filters → a report covering every compatible provider that has a completed scan.
|
||||
* `Provider type = AWS, Azure` → a report covering only your AWS and Azure scans.
|
||||
* `Provider group = Production` → a report covering only the providers in that group.
|
||||
|
||||
Changing the filters and generating again produces a different, independent report. Each combination is tracked on its own, so switching filters back and forth never overwrites a previously generated report.
|
||||
|
||||
<Note>
|
||||
Region filtering is **not** supported for the combined PDF report. The report recomputes status live across every region of the contributing scans, so a region-scoped request is rejected rather than producing a report that contradicts a region-filtered view.
|
||||
</Note>
|
||||
|
||||
### Generating and Reusing a Report
|
||||
|
||||
Because the report aggregates many scans, it is generated **asynchronously**:
|
||||
|
||||
<Steps>
|
||||
<Step title="Start the report">
|
||||
Select **Report → Generate new report…**, optionally give it a name, and confirm. Prowler starts a background job and shows a "Report generation started" confirmation.
|
||||
</Step>
|
||||
<Step title="Wait for completion">
|
||||
The button shows a "Generating report…" state while the job runs. Generation continues in the background: you can navigate away, and a toast notification appears when the report is ready, even after a page reload.
|
||||
</Step>
|
||||
<Step title="Download">
|
||||
When the report is ready, select **Download** from the notification, or use **Report → Download latest** at any time to fetch the most recent report for the current filters.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-report.png" alt="Report dropdown on the Cross-Provider Type Compliance detail page showing the Generate new report option" width="420" />
|
||||
|
||||
A report already generated for a given set of filters does not need to be generated again. When you open the detail page with a filter combination that was reported before, Prowler detects the existing report and surfaces **Report → Download latest** so you can download it immediately, without launching a new job. You only need to generate a fresh report when:
|
||||
|
||||
* You apply a filter combination that has never been reported before, or
|
||||
* A contributing provider has completed a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer completed scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one.
|
||||
|
||||
"Download latest" reuses an existing report only when it matches the framework, the exact resolved scan set for the current filters, and the same report options. This guarantees the PDF you download reflects the posture you are looking at, rather than a report generated for a different filter or an older scan.
|
||||
|
||||
<Note>
|
||||
The PDF detail section renders only **failed** requirements by default so the report stays focused as an executive/auditor document. As with every Prowler PDF, the detail section is capped at the first 100 failed findings per check; use the per-scan CSV or JSON-OCSF exports for the complete, untruncated list. See [Downloading Compliance Reports](/user-guide/compliance/tutorials/compliance#downloading-compliance-reports) for the full PDF behavior and the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` setting.
|
||||
</Note>
|
||||
|
||||
## Aggregating a Single-Provider Framework Across Providers
|
||||
|
||||
Universal frameworks answer the cross-provider-type question, but most compliance frameworks target a single provider type — CIS AWS, CIS GCP, ENS for Azure. The **Across providers** section of the Multiple Scans tab answers the sibling question for those frameworks: **"How compliant is my whole AWS estate against CIS AWS, together?"**
|
||||
|
||||
The aggregation works the same way, with the column axis swapped from provider type to individual provider, and it produces its own combined PDF report. See [Cross-Provider Compliance](/user-guide/compliance/tutorials/cross-provider-compliance) for the full guide.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
* [Cross-Provider Compliance](/user-guide/compliance/tutorials/cross-provider-compliance)
|
||||
* [Compliance](/user-guide/compliance/tutorials/compliance)
|
||||
* [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore)
|
||||
* [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework)
|
||||
* [Prowler Cloud — Getting Started](/user-guide/tutorials/prowler-app)
|
||||
@@ -2,6 +2,7 @@
|
||||
title: 'SAML SSO: Google Workspace'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { AppliesTo } from "/snippets/applies-to.mdx"
|
||||
|
||||
<AppliesTo />
|
||||
@@ -113,7 +114,12 @@ The `userType` attribute controls which Prowler role is assigned to the user:
|
||||
|
||||
- If `userType` matches an existing Prowler role name, the user receives that role automatically.
|
||||
- If `userType` does not match any existing role, Prowler Cloud creates a new role with that name **with read-only access** (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions afterward through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab.
|
||||
- If `userType` is not set, the user's existing roles are left unchanged.
|
||||
|
||||
**Fallback Role Without `userType`**
|
||||
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
If `userType` is not set, the user's existing roles are left unchanged. Users without an existing role in that tenant receive a least-privilege `read_only` fallback role until a Prowler administrator assigns another role. If `read_only` already belongs to a role with different permissions, Prowler Cloud checks suffixed names in order, starting with `read_only_0`. It reuses the first role with the fallback permissions or creates the first available name.
|
||||
|
||||
The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles.
|
||||
|
||||
|
||||
@@ -91,9 +91,15 @@ Choose a Method:
|
||||
|----------------|---------------------------------------------------------------------------------------------------------|----------|
|
||||
| `firstName` | The user's first name. | Yes |
|
||||
| `lastName` | The user's last name. | Yes |
|
||||
| `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler Cloud creates a new role with that name with read-only access (visibility over all providers, no management permissions). If `userType` is not defined, the user's existing roles are left unchanged. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No |
|
||||
| `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler Cloud creates a new role with that name with read-only access (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). If `userType` is not defined, Prowler Cloud applies the fallback behavior described below. | No |
|
||||
| `organization` | The user's company name. | No |
|
||||
|
||||
**Fallback Role Without `userType`**
|
||||
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
If `userType` is not defined, the user's existing roles are left unchanged. Users without an existing role in that tenant receive a least-privilege `read_only` fallback role. If `read_only` already belongs to a role with different permissions, Prowler Cloud checks suffixed names in order, starting with `read_only_0`. It reuses the first role with the fallback permissions or creates the first available name. A Prowler administrator can then assign the appropriate role through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac).
|
||||
|
||||
<Info>
|
||||
**IdP Attribute Mapping**
|
||||
|
||||
@@ -163,9 +169,14 @@ Choose a Method:
|
||||
|
||||
* If a role with the specified name already exists in Prowler Cloud, the user automatically receives that role.
|
||||
* If the role does not exist, Prowler Cloud creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac).
|
||||
* If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler Cloud are left unchanged.
|
||||
* `userType` must contain a single value. If the IdP sends multiple values, Prowler Cloud uses only the first value and does not assign multiple roles.
|
||||
|
||||
**Fallback Role Without `userType`**
|
||||
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler Cloud are left unchanged. Users without an existing role in that tenant receive a least-privilege `read_only` fallback role until a Prowler administrator assigns another role. If `read_only` already belongs to a role with different permissions, Prowler Cloud checks suffixed names in order, starting with `read_only_0`. It reuses the first role with the fallback permissions or creates the first available name.
|
||||
|
||||
**Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler Cloud, the user receives it automatically upon login. If it does not exist, Prowler Cloud creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed.
|
||||
|
||||
</Warning>
|
||||
|
||||
@@ -144,6 +144,21 @@ Here's what happens behind the scenes:
|
||||
- An asynchronous discovery is triggered to query your AWS Organization structure.
|
||||
- You will see a **"Gathering AWS Accounts..."** spinner — this typically takes **30 seconds to 2 minutes** depending on your organization size.
|
||||
|
||||
#### When Discovery Takes Too Long
|
||||
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
Prowler waits up to **3 minutes** for a result. Past that, the wizard stops waiting — but the discovery keeps running in the background — and offers two actions:
|
||||
|
||||
- **Keep waiting** — resume the same discovery. Nothing is re-read from AWS.
|
||||
- **Retry** — start a fresh discovery, which queries your Organization structure again.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/discovery-timeout.png" alt="Discovery timeout notice offering Keep waiting and Retry" />
|
||||
</Frame>
|
||||
|
||||
If discovery fails outright, the wizard reports the error and offers **Retry discovery**.
|
||||
|
||||
## Step 3: Select Accounts to Scan
|
||||
|
||||
### Understanding the Tree View
|
||||
@@ -186,7 +201,24 @@ You can edit the display name for each account before connecting. This alias is
|
||||
|
||||
### Blocked Accounts
|
||||
|
||||
Some accounts may appear as **blocked** (grayed out, not selectable) when the account is **already linked to a different organization** in Prowler (`linked_to_other_organization`). Hover over the blocked account to see the specific reason.
|
||||
Some accounts appear as **blocked** (grayed out, not selectable) when onboarding them would conflict with something Prowler already stores. Hover over the blocked account to see the specific reason.
|
||||
|
||||
| Reason | What it means |
|
||||
|--------|---------------|
|
||||
| `organization_conflict` | The account is already connected under a **different** Prowler organization. |
|
||||
| `organization_node_conflict` | The account is already grouped under a different organizational unit in Prowler — for example, it moved in AWS after it was onboarded. |
|
||||
|
||||
### Accounts That Already Have Credentials
|
||||
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
Applying your selection stores the organization credential on every selected account. When a selected account is already connected to Prowler with its own credential, that credential is **overwritten** — so the wizard asks first, naming the affected accounts:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/replace-credentials-apply.png" alt="Replace existing credentials modal listing the accounts whose credentials will be replaced" />
|
||||
</Frame>
|
||||
|
||||
Click **Replace and continue** to proceed, or **Cancel** to adjust your selection. Historical scans and findings are preserved either way — only the credential changes.
|
||||
|
||||
## Step 4: Test Connections
|
||||
|
||||
@@ -205,7 +237,7 @@ If every account connects successfully, you automatically advance to the next st
|
||||
|
||||
### When Some Tests Fail
|
||||
|
||||
An error banner appears: **"There was a problem connecting to some accounts."** You have two options:
|
||||
An error banner appears: **"There was a problem connecting to some accounts. Hover each account to check the error."** You have two options:
|
||||
|
||||
**a) Fix and retry:**
|
||||
1. Go to the AWS Console and verify the StackSet deployed to the failing accounts.
|
||||
@@ -219,7 +251,7 @@ Click **Skip Connection Validation** to proceed with only the accounts that conn
|
||||
<img src="/images/organizations/connection-failures-skip.png" alt="Connection test results showing failed accounts with error banner and Skip Connection Validation button" />
|
||||
</Frame>
|
||||
|
||||
If **no accounts** connected successfully, you cannot proceed. Fix the underlying connection issues — see [Troubleshooting](#troubleshooting) — and retry before launching scans.
|
||||
If **no accounts** connected successfully, the banner instead reads *"No accounts connected successfully. Fix the connection errors and retry before launching scans."* and you cannot proceed. Fix the underlying connection issues — see [Troubleshooting](#troubleshooting) — and retry before launching scans.
|
||||
|
||||
## Step 5: Launch Scans
|
||||
|
||||
@@ -236,6 +268,54 @@ After launching:
|
||||
- Results populate the **Overview** and **Findings** pages.
|
||||
- Prowler runs an **automatic sync every 6 hours** to detect accounts added to or removed from your Organization. New accounts under the targeted OU or root are onboarded automatically.
|
||||
|
||||
## Manage Your Organization After Onboarding
|
||||
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
Open the row actions menu on the organization row on the **Providers** page.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/organization-row-actions.png" alt="Row actions menu on an AWS organization row" />
|
||||
</Frame>
|
||||
|
||||
| Action | What it does |
|
||||
|--------|--------------|
|
||||
| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the name stored in AWS. |
|
||||
| **Update Credentials** | Reopens the Authentication Details step to store a new Role ARN. |
|
||||
| **Edit Scan Schedule** | Applies one schedule to every connected account in the organization. |
|
||||
| **Test Connections (N)** | Re-tests every account in the organization. |
|
||||
| **Delete Organization** | Deletes the organization and cascades to its providers. |
|
||||
|
||||
Organizational unit rows carry the same **Test Connections** and **Delete Organizational Unit** actions, scoped to the accounts beneath them.
|
||||
|
||||
### Update Organization Credentials
|
||||
|
||||
Choosing **Update Credentials** re-enters the Authentication Details step. Because the organization already holds a credential, Prowler warns before overwriting it and names how many providers re-authenticate with the new one:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/replace-credentials-setup.png" alt="Replace existing credentials modal showing how many providers re-authenticate" />
|
||||
</Frame>
|
||||
|
||||
Storing a new credential runs a fresh discovery, so any discovery already in progress is discarded — discovery authenticates with the credential it started from.
|
||||
|
||||
### Delete an Organization or Organizational Unit
|
||||
|
||||
Deleting an organization or an organizational unit **cascades to every provider grouped under it**, along with their scans and findings. Both dialogs state how many providers are affected before you confirm.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/delete-organization.png" alt="Delete organization dialog showing how many providers are deleted with it" />
|
||||
</Frame>
|
||||
|
||||
Deletion runs in the background. Prowler confirms with a **"Deletion started"** notification; if any part of it fails, the affected rows reappear on a later refresh.
|
||||
|
||||
<Danger>
|
||||
Deleting an organization **permanently deletes every account provider grouped under it**, including their historical scans and findings. This action cannot be undone.
|
||||
</Danger>
|
||||
|
||||
### When Grouping Is Unavailable
|
||||
|
||||
If Prowler cannot read your hierarchy while loading the Providers page, a notice reads *"Organization grouping is incomplete. Some providers may appear ungrouped."* Your providers are still listed, just flat. Reload the page to try again.
|
||||
|
||||
## Billing Impact
|
||||
|
||||
Each AWS account you connect through the Organizations wizard counts as one **provider** in your Prowler Cloud subscription.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 'Share Product Feedback'
|
||||
sidebarTitle: 'Share Feedback'
|
||||
description: 'Send product feedback from the floating Feedback widget in Prowler Cloud.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
Prowler Cloud provides a floating **Feedback** button for sending product feedback without leaving the current page. The button opens a short free-text form and confirms when the response has been submitted.
|
||||
|
||||
<Note>
|
||||
The widget appears only in Prowler Cloud when feedback collection and a compatible survey are available. It does not appear in Prowler Local Server. The question, supporting text, input placeholder, submit button text, and confirmation message can change over time.
|
||||
</Note>
|
||||
|
||||
## Send Feedback
|
||||
|
||||
To submit product feedback:
|
||||
|
||||
1. Sign in to Prowler Cloud.
|
||||
2. Locate the floating **Feedback** button near the lower-right corner of the page.
|
||||
3. Click **Feedback** to open the form.
|
||||
4. Enter the feedback in the text area.
|
||||
5. Click the submit button. The button remains unavailable until the response contains text other than spaces.
|
||||
6. Wait for the confirmation message to replace the form. If no custom confirmation is configured, Prowler displays **"Thanks for the feedback!"**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/feedback/feedback-widget-form.png" alt="Feedback form open in Prowler Cloud" />
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<img src="/images/feedback/feedback-widget-confirmation.png" alt="Feedback submission confirmation in Prowler Cloud" />
|
||||
</Frame>
|
||||
|
||||
The widget stays available while navigating authenticated Prowler Cloud pages, so feedback can be submitted from the page where the relevant experience occurs.
|
||||
|
||||
<Warning>
|
||||
Do not include passwords, API keys, access tokens, or other sensitive information. For account assistance or an issue that requires a response, contact [Prowler Support](https://support.prowler.com). The feedback widget does not open a support request.
|
||||
</Warning>
|
||||
|
||||
## Dismiss the Form Without Submitting
|
||||
|
||||
To close the form without sending a response, press **Esc**, click outside the form, or click **Feedback** again. Reopening the form clears any text that was not submitted.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The Feedback Button Does Not Appear
|
||||
|
||||
The widget is available only after signing in to a supported Prowler Cloud environment. If the button is absent:
|
||||
|
||||
1. Confirm that Prowler Cloud is open on an authenticated page. The widget is not available on sign-in or other public pages.
|
||||
2. Reload the page to request the current feedback survey again.
|
||||
3. Check whether a browser privacy extension or network policy blocks optional feedback services, then reload the page.
|
||||
4. Contact [Prowler Support](https://support.prowler.com) if the widget should be available but remains absent.
|
||||
|
||||
The button also remains hidden when feedback collection is disabled or no compatible free-text survey is currently available. These conditions are managed by Prowler and do not require users to configure tokens or deployment settings.
|
||||
|
||||
### The Submit Button Is Unavailable
|
||||
|
||||
Enter at least one non-space character in the text area. Empty responses and responses containing only spaces cannot be submitted.
|
||||
|
||||
### The Form Does Not Confirm Submission
|
||||
|
||||
Reload Prowler Cloud, reopen the widget, and submit the feedback again. If the form still does not show a confirmation message, contact [Prowler Support](https://support.prowler.com) and include the page where the problem occurred. Do not include credentials or other sensitive information.
|
||||
@@ -1,11 +1,442 @@
|
||||
---
|
||||
title: 'GCP Organizations'
|
||||
description: 'Onboard all GCP projects in your organization through a single guided wizard'
|
||||
tag: "Coming Soon"
|
||||
---
|
||||
|
||||
Onboarding a full GCP organization through a single guided wizard is coming soon to Prowler Cloud.
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
Today, GCP projects are onboarded individually. See [Getting Started with GCP](/user-guide/providers/gcp/getting-started-gcp) and [Bulk Provider Provisioning](/user-guide/tutorials/bulk-provider-provisioning) to automate onboarding multiple projects.
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
Keep an eye on the [changelog](https://github.com/prowler-cloud/prowler/releases) for updates.
|
||||
Prowler Cloud onboards every Google Cloud project in your organization through a single guided wizard. Instead of connecting projects one by one, you can discover every folder and project under your Google Cloud organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
|
||||
|
||||
<SubscriptionBanner>
|
||||
For Command-Line Interface (CLI) scanning of a whole organization, see [Scanning a Specific GCP Organization](/user-guide/providers/gcp/organization).
|
||||
</SubscriptionBanner>
|
||||
|
||||
To follow this guide you need an active [Prowler Cloud](https://cloud.prowler.com) account and a Google Cloud credential with read access granted **at the organization node**.
|
||||
|
||||
## Overview
|
||||
|
||||
### Individual Projects vs Organizations
|
||||
|
||||
| Approach | Best for | How it works |
|
||||
|----------|----------|--------------|
|
||||
| **Individual projects** | A few Google Cloud projects | Connect each project one by one with its own credential. |
|
||||
| **GCP Organizations** | 10+ projects, or any organization-managed estate | Connect once with an organization-level credential, discover every folder and project automatically, and scan them in bulk. |
|
||||
|
||||
### How It Works
|
||||
|
||||
Onboarding runs in four stages:
|
||||
|
||||
1. **Grant read access** to one credential at your organization node, and enable the Cloud Resource Manager Application Programming Interface (API).
|
||||
2. **Discover** — Prowler walks your hierarchy through the Cloud Resource Manager API and returns every active folder and project.
|
||||
3. **Select and connect** — choose the projects to monitor. Prowler creates one provider per project and tests every connection.
|
||||
4. **Launch scans** — apply a scan schedule across the connected projects.
|
||||
|
||||
<Note>
|
||||
**No roles are deployed into your projects.** Unlike AWS Organizations onboarding, GCP onboarding deploys nothing in Google Cloud. Prowler reuses the organization credential you provide as the credential of every project it onboards, so a single grant covers discovery and scanning.
|
||||
</Note>
|
||||
|
||||
## Before You Start
|
||||
|
||||
### Grant Read Access at the Organization Node
|
||||
|
||||
Discovery reads three Cloud Resource Manager resources: the organization itself, the folders beneath it, and the projects in each folder. Grant these permissions to the credential **directly on the organization**, not on a project:
|
||||
|
||||
| Permission | Used for |
|
||||
|------------|----------|
|
||||
| `resourcemanager.organizations.get` | Reading the organization and its display name. |
|
||||
| `resourcemanager.folders.list` | Walking the folder hierarchy. |
|
||||
| `resourcemanager.projects.list` | Listing the projects in the organization and in every folder. |
|
||||
|
||||
The **Browser (`roles/browser`)** predefined role covers all three. Scanning each project additionally needs the permissions described in [GCP Authentication in Prowler](/user-guide/providers/gcp/authentication#required-permissions) — **Viewer (`roles/viewer`)**, **Service Usage Consumer (`roles/serviceusage.serviceUsageConsumer`)**, and the custom `ProwlerRole`. Binding those at the organization node too means every project you onboard is scannable without a per-project grant:
|
||||
|
||||
```bash
|
||||
ORG_ID=123456789012
|
||||
MEMBER="serviceAccount:prowler@<credentials-project-id>.iam.gserviceaccount.com"
|
||||
|
||||
# Discovery: read the organization, its folders, and its projects
|
||||
gcloud organizations add-iam-policy-binding "$ORG_ID" \
|
||||
--member="$MEMBER" --role="roles/browser"
|
||||
|
||||
# Scanning: read resources in every project under the organization
|
||||
gcloud organizations add-iam-policy-binding "$ORG_ID" \
|
||||
--member="$MEMBER" --role="roles/viewer"
|
||||
|
||||
gcloud organizations add-iam-policy-binding "$ORG_ID" \
|
||||
--member="$MEMBER" --role="roles/serviceusage.serviceUsageConsumer"
|
||||
```
|
||||
|
||||
### Enable the Cloud Resource Manager API
|
||||
|
||||
Enable the Cloud Resource Manager API in the project that owns the credential — the service account's host project, or the quota project for user credentials:
|
||||
|
||||
```bash
|
||||
gcloud services enable cloudresourcemanager.googleapis.com \
|
||||
--project <credentials-project-id>
|
||||
```
|
||||
|
||||
### Find Your Organization ID
|
||||
|
||||
Prowler identifies your organization by its numeric Google Cloud organization ID:
|
||||
|
||||
```bash
|
||||
gcloud organizations list
|
||||
```
|
||||
|
||||
In the Google Cloud console, the ID sits in the **ID** column next to the organization on the [Manage Resources](https://console.cloud.google.com/cloud-resource-manager) page, above the folders and projects it holds:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-console-org-id.png" alt="Manage Resources page in the Google Cloud console, with the organization ID highlighted next to the organization" />
|
||||
</Frame>
|
||||
|
||||
## Step 1: Start the Organization Wizard
|
||||
|
||||
### Open the Wizard
|
||||
|
||||
1. Navigate to **Providers** and click **Add Provider**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/cloud-providers-add.png" alt="Providers page showing the Add Provider button" />
|
||||
</Frame>
|
||||
|
||||
2. Select **Google Cloud** as the provider.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/select-gcp-provider.png" alt="Provider selection modal with Google Cloud highlighted" />
|
||||
</Frame>
|
||||
|
||||
3. Choose **Add Multiple Projects With GCP Organization**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/select-gcp-organizations-method.png" alt="Method selector showing Add Multiple Projects With GCP Organization option highlighted" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
In Prowler Local Server the organization option is marked **Cloud** and opens an upgrade panel instead of the wizard. Organization-level onboarding is a Prowler Cloud feature; the single-project method remains available.
|
||||
</Note>
|
||||
|
||||
### Enter Organization Details
|
||||
|
||||
- **Organization ID**: the numeric ID of your Google Cloud organization (for example, `123456789012`). Non-numeric values are rejected before submission.
|
||||
- **Name** (optional): a display name for the organization in Prowler. If left blank, Prowler uses the name stored in Google Cloud.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-organization-details-form.png" alt="Organization Details form with the Google Cloud organization ID and Name fields" />
|
||||
</Frame>
|
||||
|
||||
Click **Next** to proceed to the authentication phase. Prowler matches the organization by ID, so submitting an organization that is already onboarded reuses it instead of creating a duplicate.
|
||||
|
||||
## Step 2: Authenticate with Google Cloud
|
||||
|
||||
The **Authentication Details** step collects the credential Prowler uses to read your hierarchy and, later, to scan each project. Choose one of two methods.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-authentication-details.png" alt="Authentication Details step showing the Service Account Key and Client ID methods" />
|
||||
</Frame>
|
||||
|
||||
### Service Account Key
|
||||
|
||||
Paste the full contents of a service account key file into **Service Account Key**. The field validates that the pasted text is a JSON object before submission.
|
||||
|
||||
To create the key for the service account you granted access to:
|
||||
|
||||
```bash
|
||||
gcloud iam service-accounts keys create prowler-key.json \
|
||||
--iam-account=prowler@<credentials-project-id>.iam.gserviceaccount.com
|
||||
```
|
||||
|
||||
### Client ID, Client Secret and Refresh Token
|
||||
|
||||
Use this method to authenticate as a Google account rather than a service account. It takes three values from an authorized-user credential:
|
||||
|
||||
- **Client ID**
|
||||
- **Client Secret**
|
||||
- **Refresh Token**
|
||||
|
||||
Running `gcloud auth application-default login` writes all three to `~/.config/gcloud/application_default_credentials.json`. The account must hold the roles listed in [Grant Read Access at the Organization Node](#grant-read-access-at-the-organization-node).
|
||||
|
||||
<Warning>
|
||||
Every project you onboard inherits this credential. Revoking it, rotating the key, or deleting the service account stops the scans of every project in the organization.
|
||||
</Warning>
|
||||
|
||||
### Authenticate and Discover
|
||||
|
||||
Click **Authenticate**. Prowler then:
|
||||
|
||||
- Creates the organization and stores the credential securely.
|
||||
- Triggers an asynchronous discovery that walks your hierarchy through the Cloud Resource Manager API.
|
||||
- Shows a **"Gathering GCP Projects..."** spinner while it waits.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-gathering-projects.png" alt="Gathering GCP Projects spinner shown while discovery runs" />
|
||||
</Frame>
|
||||
|
||||
Discovery usually takes seconds to a couple of minutes, depending on how many folders and projects your organization holds.
|
||||
|
||||
#### When Discovery Takes Too Long
|
||||
|
||||
Prowler waits up to **3 minutes** for a result. Past that, the wizard stops waiting — but the discovery keeps running in Google Cloud — and offers two actions:
|
||||
|
||||
- **Keep waiting** — resume the same discovery. Nothing is re-read from Google Cloud.
|
||||
- **Retry** — start a fresh discovery, which reads your hierarchy again.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-discovery-timeout.png" alt="Discovery timeout notice offering Keep waiting and Retry" />
|
||||
</Frame>
|
||||
|
||||
If discovery fails outright, the wizard explains why and offers **Retry discovery**. See [Troubleshooting](#troubleshooting) for each message.
|
||||
|
||||
## Step 3: Select Projects to Scan
|
||||
|
||||
### Understanding the Tree View
|
||||
|
||||
Once discovery completes, the wizard renders your organization as a hierarchical tree:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-tree-view-projects.png" alt="Hierarchical tree view showing folders and projects with selection checkboxes" />
|
||||
</Frame>
|
||||
|
||||
- **Folders** nest under the organization; projects created directly under the organization appear at the top level.
|
||||
- **Selecting a folder** selects every selectable project beneath it. A folder whose projects are only partly selected renders in an indeterminate state.
|
||||
- **Individual overrides**: deselect single projects even when the parent folder is selected.
|
||||
- The header tracks the selection as **"X of Y projects selected"**.
|
||||
- Only **ACTIVE** folders and projects appear. Projects pending deletion are not listed.
|
||||
- Folder hierarchies are read up to **10 levels** deep. Deeper organizations report an error at discovery — see [Troubleshooting](#troubleshooting).
|
||||
|
||||
### Blocked Projects
|
||||
|
||||
A project is shown grayed out and cannot be selected when onboarding it would conflict with something Prowler already stores. Hover the project to see the reason:
|
||||
|
||||
| Reason | What it means |
|
||||
|--------|---------------|
|
||||
| `organization_conflict` | The project is already connected under a **different** Prowler organization. |
|
||||
| `organization_node_conflict` | The project is already grouped under a different folder in Prowler — for example, it moved in Google Cloud after it was onboarded. |
|
||||
| `provider_type_conflict` | A provider with the same identifier exists in Prowler for another cloud provider. |
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-blocked-project.png" alt="Blocked project row with the reason shown in a tooltip" />
|
||||
</Frame>
|
||||
|
||||
### Folders With Nothing to Select
|
||||
|
||||
A folder that holds no projects, or whose projects are all blocked, is shown disabled with the note *"No projects available to select in this folder."* The folder still expands, so you can see the blocked projects it holds and why they are blocked.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-inert-folder.png" alt="Disabled folder row noting that no projects are available to select" />
|
||||
</Frame>
|
||||
|
||||
### Custom Aliases
|
||||
|
||||
Each project row carries an editable name, prefilled with the project's display name. The alias is used only inside Prowler — it does not rename anything in Google Cloud. Folder names are read-only: Prowler stores the folder display name from Google Cloud.
|
||||
|
||||
### Projects That Already Have Credentials
|
||||
|
||||
Applying your selection stores the organization credential on every selected project. When a selected project is already connected to Prowler with its own credential, that credential is **overwritten** — so the wizard asks first, naming the affected projects:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-replace-credentials-apply.png" alt="Replace existing credentials modal listing the projects whose credentials will be replaced" />
|
||||
</Frame>
|
||||
|
||||
Click **Replace and continue** to proceed, or **Cancel** to adjust your selection.
|
||||
|
||||
<Note>
|
||||
**Your existing data is safe.** A project already connected as an individual provider is **linked** to the organization, never duplicated: its historical scans and findings are preserved, and it does not count twice toward your subscription.
|
||||
</Note>
|
||||
|
||||
## Step 4: Test Connections
|
||||
|
||||
Click **Test Connections** to verify that Prowler can authenticate against each selected project. Prowler creates one provider per project — identified by its Google Cloud project ID — and then tests every connection.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-test-connections.png" alt="Connection testing in progress with status icons on each project" />
|
||||
</Frame>
|
||||
|
||||
Each project shows a real-time status indicator:
|
||||
|
||||
- **Spinner** — test in progress
|
||||
- **Green checkmark (✓)** — connection successful
|
||||
- **Red icon (✗)** — connection failed (hover to see the error)
|
||||
|
||||
If every project connects successfully, you advance to the next step automatically.
|
||||
|
||||
### When Some Tests Fail
|
||||
|
||||
An error banner appears: **"There was a problem connecting to some projects. Hover each project to check the error."** You have two options:
|
||||
|
||||
**a) Fix and retry:**
|
||||
|
||||
1. Confirm the credential holds **Viewer** and **Service Usage Consumer** on the failing projects (or on the organization).
|
||||
2. Confirm the Identity and Access Management (IAM) API is enabled as described in [GCP Authentication in Prowler](/user-guide/providers/gcp/authentication#project-level-settings).
|
||||
3. Click **Test Connections** again — only the **failed projects are re-tested**. Projects that already passed are not tested again.
|
||||
|
||||
**b) Skip and continue:**
|
||||
|
||||
Click **Skip Connection Validation** to proceed with the projects that connected successfully. Failed projects stay onboarded and visible on the Providers page, but they are not scanned. This option appears only when at least one project connected.
|
||||
|
||||
If **no project** connects, the banner instead reads *"No projects connected successfully. Fix the connection errors and retry before launching scans."* and you cannot proceed. Fix the underlying problem — see [Troubleshooting](#troubleshooting) — and retry.
|
||||
|
||||
## Step 5: Launch Scans
|
||||
|
||||
The Organizations wizard uses the same schedule controls described in [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling#schedule-options).
|
||||
|
||||
Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the selected schedule option. A toast notification confirms whether the schedule was saved, scans were launched, or both, and links to the **Scans** page. Prowler then redirects to the **Providers** page. Scans launch only for projects that passed connection testing.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-launch-scan.png" alt="Launch Scan step showing Projects Connected confirmation and the scan schedule selector" />
|
||||
</Frame>
|
||||
|
||||
After launching:
|
||||
|
||||
- Scans appear on the **Scans** page as they start and complete.
|
||||
- Results populate the **Overview** and **Findings** pages.
|
||||
- On the **Providers** page, your projects are grouped under the organization and, when they live in a folder, under that folder.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-providers-grouping.png" alt="Providers page showing projects grouped under GCP folders and the organization" />
|
||||
</Frame>
|
||||
|
||||
## Manage Your Organization After Onboarding
|
||||
|
||||
Open the row actions menu on the organization row on the **Providers** page.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-organization-row-actions.png" alt="Row actions menu on a GCP organization row" />
|
||||
</Frame>
|
||||
|
||||
| Action | What it does |
|
||||
|--------|--------------|
|
||||
| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the name stored in Google Cloud. |
|
||||
| **Update Credentials** | Reopens the Authentication Details step to store a new credential. |
|
||||
| **Edit Scan Schedule** | Applies one schedule to every connected project in the organization. |
|
||||
| **Test Connections (N)** | Re-tests every project in the organization. |
|
||||
| **Delete Organization** | Deletes the organization and cascades to its providers. |
|
||||
|
||||
### Onboard Projects Created Later
|
||||
|
||||
Projects added to your Google Cloud organization after onboarding are not picked up automatically. Run the wizard again with the same organization ID: discovery returns the current hierarchy, already-connected projects come back preselected, and the new ones are ready to select.
|
||||
|
||||
### Update Organization Credentials
|
||||
|
||||
Choosing **Update Credentials** re-enters the Authentication Details step. Because the organization already holds a credential, Prowler warns before overwriting it and names how many providers re-authenticate with the new one:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-replace-credentials-setup.png" alt="Replace existing credentials modal showing how many providers re-authenticate" />
|
||||
</Frame>
|
||||
|
||||
Storing a new credential runs a fresh discovery, so any discovery already in progress is discarded — discovery authenticates with the credential it started from.
|
||||
|
||||
### Delete an Organization or Folder
|
||||
|
||||
Deleting an organization or a folder **cascades to every provider grouped under it**, along with their scans and findings. Both dialogs state how many providers are affected before you confirm.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/gcp-delete-organization.png" alt="Delete organization dialog showing how many providers are deleted with it" />
|
||||
</Frame>
|
||||
|
||||
Deletion runs in the background. Prowler confirms with a **"Deletion started"** notification; if any part of it fails, the affected rows reappear on a later refresh.
|
||||
|
||||
<Danger>
|
||||
Deleting an organization **permanently deletes every project provider grouped under it**, including their historical scans and findings. This action cannot be undone.
|
||||
</Danger>
|
||||
|
||||
### When Grouping Is Unavailable
|
||||
|
||||
If Prowler cannot read your hierarchy while loading the Providers page, a notice reads *"Organization grouping is incomplete. Some providers may appear ungrouped."* Your providers are still listed, just flat. Reload the page to try again.
|
||||
|
||||
## Billing Impact
|
||||
|
||||
Each Google Cloud project you connect through the Organizations wizard counts as one **provider** in your Prowler Cloud subscription.
|
||||
|
||||
- **Already-connected projects**: linking an existing provider to the organization does **not** add billing. The existing provider is reused.
|
||||
- **Large organizations**: connecting a 500-project organization results in up to 500 providers on your subscription. Review your plan limits before proceeding.
|
||||
- **Deleted providers**: a project you later remove no longer counts toward your subscription.
|
||||
|
||||
For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### That Organization ID Is Not Valid
|
||||
|
||||
*"That organization ID is not valid. Copy the numeric ID from the Google Cloud console and try again."*
|
||||
|
||||
Google Cloud rejected the ID. Use only the digits — no `organizations/` prefix and no domain name. Run `gcloud organizations list` and copy the `ID` column.
|
||||
|
||||
### No Organization With That ID Was Found
|
||||
|
||||
*"No organization with that ID was found. Check the ID, and that the service account has been granted access to the organization."*
|
||||
|
||||
Either the ID belongs to another organization, or the credential cannot see this one. Confirm the binding was created **on the organization** and not on a project:
|
||||
|
||||
```bash
|
||||
gcloud organizations get-iam-policy <organization-id> \
|
||||
--flatten="bindings[].members" \
|
||||
--filter="bindings.members:<service-account-email>" \
|
||||
--format="table(bindings.role)"
|
||||
```
|
||||
|
||||
### The Service Account Cannot List Folders and Projects
|
||||
|
||||
*"The service account cannot list this organization's folders and projects. Grant it the Folder Viewer and Project Viewer roles at the organization level, then try again."*
|
||||
|
||||
The credential authenticated but lacks read access to the hierarchy. Grant **Browser (`roles/browser`)** at the organization node, as described in [Grant Read Access at the Organization Node](#grant-read-access-at-the-organization-node), and confirm the Cloud Resource Manager API is enabled in the credential's project.
|
||||
|
||||
### Authentication Failed
|
||||
|
||||
*"Authentication failed. Please verify the service account permissions or credentials, then try again."*
|
||||
|
||||
- For a service account key, confirm the key is still active and the pasted JSON is the full key file.
|
||||
- For client credentials, confirm the refresh token has not been revoked — `gcloud auth application-default login` issues a new one.
|
||||
- Confirm the service account itself is not disabled or deleted.
|
||||
|
||||
### Google Cloud Did Not Respond
|
||||
|
||||
*"Google Cloud did not respond while reading the organization. Nothing is wrong with your credentials — try again in a few minutes."*
|
||||
|
||||
A transient Cloud Resource Manager error. Click **Retry discovery**.
|
||||
|
||||
### The Folder Hierarchy Is Too Deep
|
||||
|
||||
*"This organization's folder hierarchy is deeper than Prowler can read. Contact support so we can help you onboard it."*
|
||||
|
||||
Prowler reads up to 10 levels of nested folders. Contact [Prowler Support](mailto:support@prowler.com).
|
||||
|
||||
### Discovery Never Finishes
|
||||
|
||||
The wizard stops waiting after 3 minutes, but the discovery keeps running in Google Cloud. Click **Keep waiting** to resume the same discovery rather than **Retry**, which starts over and re-reads your whole hierarchy.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### How Projects Map to Prowler Providers
|
||||
|
||||
Each selected project becomes one Prowler provider:
|
||||
|
||||
| Prowler field | Comes from |
|
||||
|---------------|------------|
|
||||
| Provider identifier | The Google Cloud project ID (for example, `prowler-prod-1`). |
|
||||
| Alias | The name you typed in the tree, or the project's display name. |
|
||||
| Credential | A copy of the organization credential. |
|
||||
|
||||
Folders that hold selected projects become grouping rows on the Providers page. You select projects only — Prowler derives the folder ancestors itself.
|
||||
|
||||
### Organization Credential vs Project Credential
|
||||
|
||||
One credential, stored twice: on the organization, where discovery reads it, and on each project provider, where scans read it. That is why replacing the organization credential re-authenticates every project under it, and why the wizard asks before overwriting a project's own credential.
|
||||
|
||||
## What's Next
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Prowler Cloud" icon="cloud" href="/user-guide/tutorials/prowler-app">
|
||||
Full guide to using Prowler Cloud features.
|
||||
</Card>
|
||||
<Card title="GCP Organizations (CLI)" icon="terminal" href="/user-guide/providers/gcp/organization">
|
||||
CLI-based scanning of a specific Google Cloud organization.
|
||||
</Card>
|
||||
<Card title="GCP Authentication" icon="key" href="/user-guide/providers/gcp/authentication">
|
||||
Credential types and the permissions Prowler needs in Google Cloud.
|
||||
</Card>
|
||||
<Card title="Bulk Provider Provisioning" icon="upload" href="/user-guide/tutorials/bulk-provider-provisioning">
|
||||
Script-based bulk provisioning for advanced automation.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -31,9 +31,9 @@ The **Change Plan** dialog opens on **Choose your plan**, with the same **Monthl
|
||||
|
||||

|
||||
|
||||
Select a plan and confirm with **Change Plan**. Choosing the annual plan also asks for the upfront provider account count. The change is submitted to Prowler Cloud and applied asynchronously; the customer's row updates once it lands. If it fails, a message explains why:
|
||||
Select a plan and confirm with **Change Plan**. Choosing the annual plan also asks for the upfront provider account count. The change is submitted to Prowler Cloud and applied asynchronously; the customer's row updates once it lands. If it fails, use the reported problem to identify the cause:
|
||||
|
||||
| Message | Cause |
|
||||
| Problem | Cause |
|
||||
|---|---|
|
||||
| Cloud accounts count is required | The annual plan was selected without a provider account count. |
|
||||
| Cloud accounts count out of range | The count is outside 1–20. |
|
||||
|
||||
@@ -3,11 +3,11 @@ title: "Customizing Your Branding in Prowler for MSPs and MSSPs"
|
||||
sidebarTitle: "Branding"
|
||||
---
|
||||
|
||||
Upload your company logo to have it displayed alongside Prowler branding in the console. Branding is managed from **Settings → Branding** and requires a role with **Manage settings**.
|
||||
Upload and save your company logo, then preview its intended placement alongside Prowler branding. Branding is managed from **Settings → Branding** and requires a role with **Manage settings**.
|
||||
|
||||
## Upload a Logo
|
||||
|
||||
Open **Settings → Branding**, click **Upload Logo**, and pick your file. The logo replaces the placeholder immediately and a confirmation appears.
|
||||
Open **Settings → Branding**, click **Upload Logo**, and pick your file. The logo replaces the placeholder in Settings immediately and a confirmation appears.
|
||||
|
||||

|
||||
|
||||
@@ -36,24 +36,16 @@ Uploaded SVG files are sanitized on the server. Scripts, external references and
|
||||
|
||||
## Replace or Remove a Logo
|
||||
|
||||
Uploading a new file replaces the current one. **Remove Logo** deletes it and returns the console to the default Prowler branding. Both take effect immediately for everyone on your team.
|
||||
Uploading a new file replaces the saved logo. **Remove Logo** deletes it and returns the Settings preview to the *Your Logo* placeholder.
|
||||
|
||||
## Logo Placement Preview
|
||||
|
||||
Below the upload controls, **Logo Placement Preview** renders your logo underneath the Prowler wordmark, showing how the two are intended to sit together in a Prowler Cloud organization. With no logo uploaded, the slot shows a *Your Logo* placeholder.
|
||||
Below the upload controls, **Logo Placement Preview** renders your logo underneath the Prowler wordmark to show the intended placement. With no logo uploaded, the slot shows a *Your Logo* placeholder.
|
||||
|
||||
<Note>
|
||||
The preview is rendered locally in the console. Uploading a logo does not currently push it to Prowler Cloud.
|
||||
The saved logo is currently displayed only in the upload area and placement preview on this Settings page. It is not applied elsewhere in Partner Portal, Prowler Cloud or customer reports.
|
||||
</Note>
|
||||
|
||||
## Where Your Logo Appears
|
||||
|
||||
Your logo is shown alongside Prowler branding within the console for your team.
|
||||
|
||||
<Warning>
|
||||
**Branding does not extend to Prowler Cloud.** Customers signing in at [cloud.prowler.com](https://cloud.prowler.com) see the standard Prowler interface. White-labeling Prowler Cloud is not supported.
|
||||
</Warning>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
|
||||
@@ -7,10 +7,10 @@ Adding a customer in Prowler for MSPs and MSSPs provisions a Prowler Cloud tenan
|
||||
|
||||
## Add a Customer
|
||||
|
||||
Select **Customers** in the sidebar, then click **Add Customer** to open a three-step wizard.
|
||||
If you are a Superadmin, select **Customers** in the sidebar, then click **Add Customer** to open a three-step wizard.
|
||||
|
||||
<Note>
|
||||
Adding a customer is restricted to the Superadmin role. Organization Admins manage existing customers but do not see the **Add Customer** button.
|
||||
In the Partner Portal UI, the **Add Customer** action is currently shown to Superadmins. Organization Admins manage existing customers but do not see the action.
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
@@ -27,13 +27,13 @@ Adding a customer is restricted to the Superadmin role. Organization Admins mana
|
||||
</Step>
|
||||
|
||||
<Step title="Launch Their Customer Workspace">
|
||||
A banner confirms the outcome — created on trial, or subscribed to a paid plan — and the tenant is created and linked to your organization. Click **Go To Organization** to head straight there and start connecting cloud providers, or **Close** to return to the customer list.
|
||||
Wait while the tenant is created and linked to your organization. A banner then confirms that the customer was created on trial or that paid-plan enrollment was submitted. Click **Go To Organization** to head straight there and start connecting cloud providers, or **Close** to return to the customer list.
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Provisioning is asynchronous. The customer's row shows **Provisioning** until the tenant is ready, then switches to **Active**.
|
||||
Customer creation waits for tenant provisioning to finish. A successful submission adds the customer as **Active**. If you selected a paid plan, enrollment continues asynchronously and the billing status updates when it completes.
|
||||
|
||||
## The Customers View
|
||||
|
||||
@@ -53,11 +53,9 @@ Each row carries:
|
||||
| **Billing Type** | The customer's current plan, shown as **Trial**, **Pro Monthly** or **Pro Annual** |
|
||||
| **MTD** | Month-to-date spend |
|
||||
| **Last Month Expenses** | Previous month's total |
|
||||
| **Status** | **Active**, **Provisioning** or **Inactive** |
|
||||
| **Status** | The customer's current status |
|
||||
| **Last scan completed** | When the most recent scan finished |
|
||||
|
||||
A customer shows as **Provisioning** while its tenant is being created, and **Inactive** once it has been marked for removal.
|
||||
|
||||
Above the table, search by name and filter by provider or status. The download button at the top right of the table exports the list.
|
||||
|
||||
## Open a Customer's Prowler Cloud Tenant
|
||||
@@ -88,10 +86,6 @@ The customer-side flow that consumes the Partner Code is rolling out progressive
|
||||
|
||||
Your customers keep signing in to [cloud.prowler.com](https://cloud.prowler.com) with their own users. Your access is additive — it neither replaces nor restricts theirs.
|
||||
|
||||
## Remove a Customer
|
||||
|
||||
Removing a customer detaches it from your partner organization and moves it to **Inactive**. It does not delete the customer's data in Prowler Cloud; contact Prowler to arrange the offboarding of the tenant itself.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
|
||||
@@ -20,9 +20,9 @@ A rejection email carries the reason, categorized as **Incomplete documentation*
|
||||
|
||||
## Settings
|
||||
|
||||
Open **Settings** from the sidebar. The page has three tabs — **Profile**, **Branding** and **Security** — though what you can do in them depends on your role.
|
||||
Open **Settings** from the sidebar. The tabs depend on your role. Users with **Manage settings** see **Profile**, **Branding**, **Security** and a disabled **Notifications** tab. Users without **Manage settings** see only **Security**.
|
||||
|
||||
Every signed-in user can view the settings and change their own password. Editing the organization itself requires a role with **Manage settings**; without it, only the **Security** tab is available.
|
||||
Every signed-in user can change their own password. Editing the organization itself requires a role with **Manage settings**.
|
||||
|
||||
### Profile
|
||||
|
||||
@@ -39,7 +39,7 @@ Upload your logo. See [Customizing Your Branding](/user-guide/tutorials/prowler-
|
||||
|
||||
### Security
|
||||
|
||||
Change your own password. Roles with **Manage settings** — Superadmin today — also reach the Danger zone described below. An Organization Admin sees the password form only.
|
||||
Change your own password. Users with **Manage settings** can also see the Danger zone described below, but only the partner owner can submit a deletion request. An Organization Admin sees the password form only.
|
||||
|
||||
## Customer Capacity
|
||||
|
||||
@@ -51,11 +51,11 @@ Deleting a partner organization is a request, not an immediate action.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the Danger zone">
|
||||
Go to **Settings → Security**. The Danger zone and its **Delete Partner** action require a role with **Manage settings**.
|
||||
Go to **Settings → Security**. The Danger zone requires **Manage settings**, and submitting its **Delete Partner** request is restricted to the partner owner.
|
||||
</Step>
|
||||
|
||||
<Step title="File the request">
|
||||
Optionally give a reason in **Reason for deletion**, then type `DELETE` in the confirmation field to enable the button and submit.
|
||||
Enter a required **Reason for deletion**. The UI accepts 10–1000 characters, and the API rejects reasons shorter than 10 characters. Then type `DELETE` in the confirmation field to enable the button and submit.
|
||||
</Step>
|
||||
|
||||
<Step title="Wait for review">
|
||||
@@ -73,7 +73,7 @@ Closing a partner organization does not delete customer data in Prowler Cloud on
|
||||
|
||||
## Ownership
|
||||
|
||||
One user is the **owner** of the partner organization — by default, whoever signed up. Ownership can be transferred to another team member, who keeps the Superadmin role. The owner cannot be removed from the team while they hold ownership.
|
||||
One user is the **owner** of the partner organization — by default, whoever signed up. Ownership transfer is Prowler-assisted rather than self-service: contact Prowler to request it. Prowler staff can transfer ownership only when the partner has no customer organizations and the target is an active Superadmin. The previous owner keeps the Superadmin role, and the current owner cannot be removed from the team while they hold ownership.
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -41,11 +41,10 @@ Every password in the console — at sign-up, when accepting an invitation, and
|
||||
| **Number** | At least 1 digit |
|
||||
| **Special character** | At least 1 special character |
|
||||
| **Not common** | Rejected if it appears on the common-password list |
|
||||
| **Not similar to your details** | Rejected if too close to your name or email |
|
||||
|
||||
## Sign In
|
||||
|
||||
Sign in at [partners.prowler.com](https://partners.prowler.com) with your email and password, then click **Login**. Select **Remember me** to keep the session alive longer between visits.
|
||||
Sign in at [partners.prowler.com](https://partners.prowler.com) with your email and password, then click **Login**.
|
||||
|
||||

|
||||
|
||||
@@ -68,9 +67,9 @@ The confirmation banner appears whether or not the email matches an account, so
|
||||
|
||||
## Sessions
|
||||
|
||||
Access tokens are short-lived — **30 minutes** by default — and refresh automatically while you are active. The refresh window is **24 hours**, extended to **7 days** when you select **Remember me**. Refresh tokens rotate on every refresh and the previous one is revoked.
|
||||
Portal sessions currently use a sliding **23-hour** lifetime. Continued use may renew the session window, but you should expect to sign in again after extended inactivity. Selecting **Remember me** does not provide a seven-day Portal session.
|
||||
|
||||
If a refresh fails — after long inactivity, a revoked token, or a server restart — you return to the sign-in screen with a `RefreshAccessTokenError` notice. Sign in again to continue.
|
||||
If session refresh or validation fails, you are redirected to the sign-in screen. Sign in again to continue.
|
||||
|
||||
## Sign Out
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Each role is a set of permission flags:
|
||||
| **Manage members** | Invite, re-invite, disable, enable and remove team members | ✓ | |
|
||||
| **Manage settings** | Edit the organization profile and branding | ✓ | |
|
||||
| **Manage billing** | Manage customer plans | ✓ | ✓ |
|
||||
| **Manage organizations** | Remove customers and edit their details | ✓ | ✓ |
|
||||
| **Manage organizations** | Edit existing customer details | ✓ | ✓ |
|
||||
| **Access tenants** | Open a customer's Prowler Cloud tenant | ✓ | ✓ |
|
||||
|
||||
<Note>
|
||||
@@ -40,7 +40,7 @@ The Prowler Cloud-side permission level for a team member is provisioned automat
|
||||
</Step>
|
||||
|
||||
<Step title="Track the invitation">
|
||||
The team table lists **Name**, **Email**, **Role**, **Status** and **User Event**. An invitation is **Pending**, **Accepted**, **Expired** or **Revoked**.
|
||||
The team table lists **Name**, **Email**, **Role**, **Status** and **User Event** for members and pending invitations. A pending invitation may display as **Expired** after its expiry passes. Accepted invitations become member rows, while revoked invitations are no longer shown.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ To bulk edit provider schedules:
|
||||
4. Click **Edit Scan Schedule (N)**, where **N** is the number of selected providers.
|
||||
5. Save the schedule.
|
||||
|
||||
For AWS Organizations and Organizational Unit rows, **Edit Scan Schedule** applies the schedule to the connected child providers in that group.
|
||||
For organization rows and their grouping rows — AWS organizational units, GCP folders — **Edit Scan Schedule** applies the schedule to the connected child providers in that group.
|
||||
|
||||
<Warning>
|
||||
Bulk schedule edits apply one schedule to every selected provider. If the wrong providers are selected, Prowler applies the same cadence to unintended providers. To recover, reopen bulk edit with the correct selection or update affected provider schedules individually.
|
||||
|
||||
@@ -12,16 +12,26 @@ Full access to your Prowler data (Prowler Cloud, Prowler Private Cloud, or Prowl
|
||||
- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments
|
||||
- **Finding Groups Analysis**: Triage findings grouped by check ID and drill down into affected resources
|
||||
- **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
|
||||
- **Scan Orchestration**: Trigger on-demand scans, track their progress, and schedule a daily scan
|
||||
- **Resource Inventory**: Search and view detailed information about your audited resources
|
||||
- **Muting Management**: Create and manage muting rules to suppress non-critical findings
|
||||
- **Compliance Reporting**: View compliance status across frameworks and drill into requirement-level details
|
||||
- **Attack Paths Analysis**: Analyze privilege escalation chains through graph-based analysis of cloud resource relationships
|
||||
- **Integrations Management**: Set up and troubleshoot where Prowler sends its results (Amazon S3, AWS Security Hub, Jira), and turn findings into Jira work items
|
||||
- **User & Role Management**: List the users in your tenant, identify the authenticated user, browse RBAC roles, and set the role a user holds
|
||||
|
||||
### Prowler Cloud Management
|
||||
|
||||
Prowler Cloud-only workflow and configuration features (`prowler_cloud_*` tools). These are available only on the [hosted Prowler MCP](#1-hosted-prowler-mcp-recommended), since they manage features that exist only in Prowler Cloud:
|
||||
- **Scan Configurations**: Read, create, update, and delete reusable scan configurations and attach them to providers (providers without one use the default)
|
||||
- **Findings Triage**: Read and set a finding's triage status and leave notes documenting the decision, without suppressing the finding
|
||||
- **Scan Scheduling**: Read and configure recurring scan schedules (daily, interval, weekly, monthly), one provider at a time or in bulk
|
||||
- **Alerts**: Read and manage alert rules and recipients, dry-run rule conditions before saving, and browse the fired-alert history
|
||||
|
||||
### Prowler Hub
|
||||
|
||||
Access to Prowler's comprehensive security knowledge base:
|
||||
- **Security Checks Catalog**: Browse and search **over 1000 security checks** across multiple Prowler providers
|
||||
- **Security Checks Catalog**: Browse and search **over 2,000 security checks** across multiple Prowler providers
|
||||
- **Check Implementation**: View the Python code that powers each security check
|
||||
- **Automated Fixers**: Access remediation scripts for common security issues
|
||||
- **Compliance Frameworks**: Explore mappings to **over 70 compliance standards and frameworks**
|
||||
@@ -128,6 +138,7 @@ For complete tool descriptions and parameters, see the [Tools Reference](https:/
|
||||
|
||||
All tools follow a consistent naming pattern with prefixes:
|
||||
- `prowler_*` - Prowler Cloud, Prowler Private Cloud & Prowler Local Server management tools
|
||||
- `prowler_cloud_*` - Prowler Cloud-only management tools (hosted Prowler MCP only)
|
||||
- `prowler_hub_*` - Prowler Hub catalog and compliance tools
|
||||
- `prowler_docs_*` - Prowler documentation search and retrieval
|
||||
|
||||
@@ -135,7 +146,7 @@ All tools follow a consistent naming pattern with prefixes:
|
||||
|
||||
```text
|
||||
prowler_mcp_server/
|
||||
├── server.py # Main orchestrator (imports sub-servers with prefixes)
|
||||
├── server.py # Main orchestrator (mounts sub-servers with namespaces)
|
||||
├── main.py # CLI entry point
|
||||
├── prowler_hub/ # tools - no authentication required
|
||||
├── prowler_app/ # tools - authentication required
|
||||
@@ -159,7 +170,15 @@ The Prowler MCP Server enables powerful workflows through AI assistants:
|
||||
|
||||
- "Show me all critical findings from my AWS production accounts"
|
||||
- "Register my new AWS account in Prowler and run a scheduled scan every day"
|
||||
- "List all muted findings and detect what findgings are muted by a not enough good reason in relation to their severity"
|
||||
- "List all muted findings and flag the ones whose mute reason is too weak for their severity"
|
||||
- "Send my failed CIS findings for this provider to Jira as work items"
|
||||
|
||||
### Prowler Cloud Management
|
||||
|
||||
- "Preview an alert rule for critical AWS findings and create it for my confirmed recipients"
|
||||
- "Show the triage notes for this finding and mark it as under review"
|
||||
- "Apply a weekly Monday 06:00 scan schedule to every AWS provider"
|
||||
- "Create a scan configuration that runs only CIS checks and attach it to my production providers"
|
||||
|
||||
### Security Research
|
||||
|
||||
@@ -185,7 +204,7 @@ The Prowler MCP Server enables powerful workflows through AI assistants:
|
||||
- `https://docs.prowler.com` (for Prowler Documentation)
|
||||
- Prowler Cloud API or Prowler Local Server API (for Prowler features)
|
||||
|
||||
> **No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication. A Prowler API key is only required to access Prowler features (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
|
||||
> **No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication. A Prowler API key is only required for the `prowler_*` and `prowler_cloud_*` tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
|
||||
|
||||
## Configuring MCP Hosts
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Integrations tools to manage Amazon S3, AWS Security Hub and Jira integrations, and to send findings to Jira
|
||||
@@ -0,0 +1 @@
|
||||
`prowler_list_integrations` failing with a 500 error on tenants with a Jira integration, caused by the request leaving `configuration` out of the sparse fieldset
|
||||