Merge branch 'master' into PROWLER-1511-okta-stig-signon-service-global-session-policy-checks-sdk

This commit is contained in:
Daniel Barranquero
2026-05-20 17:36:31 +02:00
87 changed files with 3728 additions and 37 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"name": "prowler-plugins",
"description": "Prowler Cloud Security for Claude Code",
"owner": {
"name": "Prowler",
"email": "support@prowler.com"
},
"plugins": [
{
"name": "prowler",
"source": "./claude_plugins/prowler",
"description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
"category": "security",
"homepage": "https://prowler.com"
}
]
}
+27 -1
View File
@@ -133,11 +133,37 @@ jobs:
with:
persist-credentials: false
- name: Pin prowler SDK to latest master commit
- name: Pin prowler SDK to latest master commit and refresh lockfile
if: github.event_name == 'push'
run: |
set -e
LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git refs/heads/master | cut -f1)
sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml
# Refresh api/uv.lock so it matches the pinned SHA above; the API
# Dockerfile runs `uv sync --locked`, which aborts on any drift
# between pyproject.toml and uv.lock.
pip install --no-cache-dir "uv==0.11.14"
(cd api && uv lock)
- name: Pin prowler SDK to latest release branch (v5.Y) commit and refresh lockfile
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
set -e
# RELEASE_TAG looks like "5.14.0"; the SDK release branch is "v5.14".
VERSION="${RELEASE_TAG#v}"
VERSION_BRANCH="v$(echo "${VERSION}" | cut -d. -f1,2)"
LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git "refs/heads/${VERSION_BRANCH}" | cut -f1)
if [ -z "${LATEST_SHA}" ]; then
echo "ERROR: release branch ${VERSION_BRANCH} not found in prowler-cloud/prowler"
exit 1
fi
echo "Pinning SDK to ${VERSION_BRANCH}@${LATEST_SHA}"
sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml
# Refresh api/uv.lock so it matches the pinned SHA above; the API
# Dockerfile runs `uv sync --locked`, which aborts on any drift
# between pyproject.toml and uv.lock.
pip install --no-cache-dir "uv==0.11.14"
(cd api && uv lock)
- name: Login to DockerHub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
+33
View File
@@ -139,6 +139,17 @@ jobs:
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Regenerate lockfiles after version bump
run: |
set -e
# The bumps above edit pyproject.toml / api/pyproject.toml but leave
# uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in
# the container builds. Refresh both with the uv version the images
# pin (plain `uv lock`, no --upgrade: only the version line changes).
pip install --no-cache-dir "uv==0.11.14"
uv lock
(cd api && uv lock)
- name: Bump UI version (.env)
run: |
set -e
@@ -240,6 +251,17 @@ jobs:
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${FIRST_API_PATCH_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${FIRST_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Regenerate lockfiles after version bump
run: |
set -e
# The bumps above edit pyproject.toml / api/pyproject.toml but leave
# uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in
# the container builds. Refresh both with the uv version the images
# pin (plain `uv lock`, no --upgrade: only the version line changes).
pip install --no-cache-dir "uv==0.11.14"
uv lock
(cd api && uv lock)
- name: Bump UI version (.env)
run: |
set -e
@@ -341,6 +363,17 @@ jobs:
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_PATCH_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Regenerate lockfiles after version bump
run: |
set -e
# The bumps above edit pyproject.toml / api/pyproject.toml but leave
# uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in
# the container builds. Refresh both with the uv version the images
# pin (plain `uv lock`, no --upgrade: only the version line changes).
pip install --no-cache-dir "uv==0.11.14"
uv lock
(cd api && uv lock)
- name: Bump UI version (.env)
run: |
set -e
-1
View File
@@ -60,7 +60,6 @@ htmlcov/
**/mcp-config.json
**/mcpServers.json
.mcp/
.mcp.json
# AI Coding Assistants - Cursor
.cursorignore
+6 -6
View File
@@ -76,11 +76,11 @@ USER prowler
WORKDIR /home/prowler
# Copy necessary files
COPY prowler/ /home/prowler/prowler/
COPY dashboard/ /home/prowler/dashboard/
COPY pyproject.toml uv.lock /home/prowler/
COPY README.md /home/prowler/
COPY prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py
COPY --chown=prowler:prowler prowler/ /home/prowler/prowler/
COPY --chown=prowler:prowler dashboard/ /home/prowler/dashboard/
COPY --chown=prowler:prowler pyproject.toml uv.lock /home/prowler/
COPY --chown=prowler:prowler README.md /home/prowler/
COPY --chown=prowler:prowler prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py
# Install Python dependencies
ENV HOME='/home/prowler'
@@ -89,7 +89,7 @@ ENV PATH="${HOME}/.local/bin:${PATH}"
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.11.14
RUN uv sync --compile-bytecode && \
RUN uv sync --locked --compile-bytecode && \
rm -rf ~/.cache/uv
# Install PowerShell modules
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.29.0] (Prowler UNRELEASED)
### 🚀 Added
- `okta` provider support [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184)
---
## [1.28.0] (Prowler v5.27.0)
### 🚀 Added
+2 -2
View File
@@ -89,7 +89,7 @@ WORKDIR /home/prowler
# Ensure output directory exists
RUN mkdir -p /tmp/prowler_api_output
COPY pyproject.toml uv.lock ./
COPY --chown=prowler:prowler pyproject.toml uv.lock ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.11.14
@@ -97,7 +97,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
ENV PATH="/home/prowler/.local/bin:$PATH"
# Add `--no-install-project` to avoid installing the current project as a package
RUN uv sync --no-install-project && \
RUN uv sync --locked --no-install-project && \
rm -rf ~/.cache/uv
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
@@ -0,0 +1,41 @@
from django.db import migrations
import api.db_utils
class Migration(migrations.Migration):
dependencies = [
("api", "0092_findings_arrays_gin_index_parent"),
]
operations = [
migrations.AlterField(
model_name="provider",
name="provider",
field=api.db_utils.ProviderEnumField(
choices=[
("aws", "AWS"),
("azure", "Azure"),
("gcp", "GCP"),
("kubernetes", "Kubernetes"),
("m365", "M365"),
("github", "GitHub"),
("mongodbatlas", "MongoDB Atlas"),
("iac", "IaC"),
("oraclecloud", "Oracle Cloud Infrastructure"),
("alibabacloud", "Alibaba Cloud"),
("cloudflare", "Cloudflare"),
("openstack", "OpenStack"),
("image", "Image"),
("googleworkspace", "Google Workspace"),
("vercel", "Vercel"),
("okta", "Okta"),
],
default="aws",
),
),
migrations.RunSQL(
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'okta';",
reverse_sql=migrations.RunSQL.noop,
),
]
+27
View File
@@ -296,6 +296,7 @@ class Provider(RowLevelSecurityProtectedModel):
IMAGE = "image", _("Image")
GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace")
VERCEL = "vercel", _("Vercel")
OKTA = "okta", _("Okta")
@staticmethod
def validate_aws_uid(value):
@@ -354,6 +355,26 @@ class Provider(RowLevelSecurityProtectedModel):
pointer="/data/attributes/uid",
)
@staticmethod
def validate_okta_uid(value):
if not re.match(
r"^[a-z0-9][a-z0-9-]*\.("
r"okta\.com|oktapreview\.com|okta-emea\.com|"
r"okta-gov\.com|okta\.mil|okta-miltest\.com|trex-govcloud\.com"
r")$",
value,
):
raise ModelValidationError(
detail=(
"Okta provider ID must be a valid Okta-managed org domain "
"(e.g., acme.okta.com, also .oktapreview.com / .okta-emea.com "
"/ .okta-gov.com / .okta.mil / .okta-miltest.com / "
".trex-govcloud.com), without scheme or path."
),
code="okta-uid",
pointer="/data/attributes/uid",
)
@staticmethod
def validate_kubernetes_uid(value):
if not re.match(
@@ -480,6 +501,12 @@ class Provider(RowLevelSecurityProtectedModel):
def clean(self):
super().clean()
if self.provider == self.ProviderChoices.OKTA and self.uid:
# Mirror the SDK, which lowercases the org domain before connecting.
# Without this the API would reject Acme.okta.com even though the
# SDK would accept it, and stored uids could disagree with the
# authenticated org domain.
self.uid = self.uid.strip().lower()
getattr(self, f"validate_{self.provider}_uid")(self.uid)
def save(self, *args, **kwargs):
+174
View File
@@ -373,6 +373,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -389,6 +390,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -412,6 +414,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -430,6 +433,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -1453,6 +1457,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -1469,6 +1474,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -1491,6 +1497,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -1509,6 +1516,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -1997,6 +2005,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -2013,6 +2022,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -2035,6 +2045,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -2053,6 +2064,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -2584,6 +2596,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -2600,6 +2613,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -2622,6 +2636,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -2640,6 +2655,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -3134,6 +3150,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -3150,6 +3167,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -3173,6 +3191,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -3191,6 +3210,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -3740,6 +3760,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -3756,6 +3777,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -3779,6 +3801,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -3797,6 +3820,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -4254,6 +4278,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -4270,6 +4295,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -4293,6 +4319,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -4311,6 +4338,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -4766,6 +4794,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -4782,6 +4811,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -4805,6 +4835,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -4823,6 +4854,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -5266,6 +5298,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -5282,6 +5315,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -5305,6 +5339,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -5323,6 +5358,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -7156,6 +7192,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -7172,6 +7209,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -7195,6 +7233,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -7213,6 +7252,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -7335,6 +7375,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -7351,6 +7392,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -7374,6 +7416,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -7392,6 +7435,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -7503,6 +7547,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -7519,6 +7564,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -7541,6 +7587,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -7559,6 +7606,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -7702,6 +7750,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -7718,6 +7767,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -7741,6 +7791,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -7759,6 +7810,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -7915,6 +7967,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -7931,6 +7984,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -7954,6 +8008,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -7972,6 +8027,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -8122,6 +8178,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -8138,6 +8195,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -8160,6 +8218,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -8178,6 +8237,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -8370,6 +8430,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -8386,6 +8447,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -8409,6 +8471,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -8427,6 +8490,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -8548,6 +8612,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -8564,6 +8629,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -8587,6 +8653,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -8605,6 +8672,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -8750,6 +8818,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -8766,6 +8835,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -8789,6 +8859,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -8807,6 +8878,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -9593,6 +9665,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -9609,6 +9682,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider__in]
schema:
@@ -9632,6 +9706,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -9650,6 +9725,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -9673,6 +9749,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -9689,6 +9766,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -9712,6 +9790,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -9730,6 +9809,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -10400,6 +10480,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -10416,6 +10497,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -10439,6 +10521,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -10457,6 +10540,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -10951,6 +11035,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -10967,6 +11052,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -10990,6 +11076,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -11008,6 +11095,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -11315,6 +11403,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -11331,6 +11420,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -11354,6 +11444,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -11372,6 +11463,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -11685,6 +11777,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -11701,6 +11794,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -11724,6 +11818,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -11742,6 +11837,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -12580,6 +12676,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -12596,6 +12693,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -12619,6 +12717,7 @@ paths:
- openstack
- oraclecloud
- vercel
- okta
description: |-
Multiple values may be separated by commas.
@@ -12637,6 +12736,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
explode: false
style: form
- in: query
@@ -20115,6 +20215,23 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
- type: object
title: Okta OAuth Credentials
properties:
okta_client_id:
type: string
description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.
okta_private_key:
type: string
description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.
okta_scopes:
type: array
items:
type: string
description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.
required:
- okta_client_id
- okta_private_key
- type: object
title: Vercel API Token
properties:
@@ -21127,6 +21244,7 @@ components:
- image
- googleworkspace
- vercel
- okta
type: string
description: |-
* `aws` - AWS
@@ -21144,6 +21262,7 @@ components:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
x-spec-enum-id: 91f917e0c3ab97e8
uid:
type: string
@@ -21265,6 +21384,7 @@ components:
- image
- googleworkspace
- vercel
- okta
type: string
x-spec-enum-id: 91f917e0c3ab97e8
description: |-
@@ -21285,6 +21405,7 @@ components:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
uid:
type: string
title: Unique identifier for the provider, set by the provider
@@ -21337,6 +21458,7 @@ components:
- image
- googleworkspace
- vercel
- okta
type: string
x-spec-enum-id: 91f917e0c3ab97e8
description: |-
@@ -21357,6 +21479,7 @@ components:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
* `okta` - Okta
uid:
type: string
minLength: 3
@@ -22206,6 +22329,23 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
- type: object
title: Okta OAuth Credentials
properties:
okta_client_id:
type: string
description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.
okta_private_key:
type: string
description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.
okta_scopes:
type: array
items:
type: string
description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.
required:
- okta_client_id
- okta_private_key
- type: object
title: Vercel API Token
properties:
@@ -22631,6 +22771,23 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
- type: object
title: Okta OAuth Credentials
properties:
okta_client_id:
type: string
description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.
okta_private_key:
type: string
description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.
okta_scopes:
type: array
items:
type: string
description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.
required:
- okta_client_id
- okta_private_key
- type: object
title: Vercel API Token
properties:
@@ -23066,6 +23223,23 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
- type: object
title: Okta OAuth Credentials
properties:
okta_client_id:
type: string
description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.
okta_private_key:
type: string
description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.
okta_scopes:
type: array
items:
type: string
description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.
required:
- okta_client_id
- okta_private_key
- type: object
title: Vercel API Token
properties:
+31
View File
@@ -31,6 +31,7 @@ from prowler.providers.image.image_provider import ImageProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider
from prowler.providers.okta.okta_provider import OktaProvider
from prowler.providers.openstack.openstack_provider import OpenstackProvider
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
from prowler.providers.vercel.vercel_provider import VercelProvider
@@ -130,6 +131,7 @@ class TestReturnProwlerProvider:
(Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider),
(Provider.ProviderChoices.IMAGE.value, ImageProvider),
(Provider.ProviderChoices.VERCEL.value, VercelProvider),
(Provider.ProviderChoices.OKTA.value, OktaProvider),
],
)
def test_return_prowler_provider(self, provider_type, expected_provider):
@@ -238,6 +240,31 @@ class TestProwlerProviderConnectionTest:
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_okta_provider(
self, mock_return_prowler_provider
):
"""Test connection test for Okta provider passes org domain and provider_id."""
provider = MagicMock()
provider.uid = "acme.okta.com"
provider.provider = Provider.ProviderChoices.OKTA.value
provider.secret.secret = {
"okta_client_id": "0oa123456789abcdef",
"okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
"okta_scopes": ["okta.policies.read"],
}
mock_return_prowler_provider.return_value = MagicMock()
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
okta_client_id="0oa123456789abcdef",
okta_private_key="-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
okta_scopes=["okta.policies.read"],
okta_org_domain="acme.okta.com",
provider_id="acme.okta.com",
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_image_provider_no_creds(
self, mock_return_prowler_provider
@@ -308,6 +335,10 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.VERCEL.value,
{"team_id": "provider_uid"},
),
(
Provider.ProviderChoices.OKTA.value,
{"okta_org_domain": "provider_uid"},
),
],
)
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
+108 -3
View File
@@ -1625,6 +1625,21 @@ class TestProviderViewSet:
"uid": "C12",
"alias": "Google Workspace Minimum Length",
},
{
"provider": "okta",
"uid": "acme.okta.com",
"alias": "Okta Org",
},
{
"provider": "okta",
"uid": "agency.okta-gov.com",
"alias": "Okta Gov Org",
},
{
"provider": "okta",
"uid": "agency.okta.mil",
"alias": "Okta Mil Org",
},
]
),
)
@@ -2143,6 +2158,24 @@ class TestProviderViewSet:
"googleworkspace-uid",
"uid",
),
(
{
"provider": "okta",
"uid": "https://acme.okta.com",
"alias": "test",
},
"okta-uid",
"uid",
),
(
{
"provider": "okta",
"uid": "acme.example.com",
"alias": "test",
},
"okta-uid",
"uid",
),
]
),
)
@@ -2163,6 +2196,25 @@ class TestProviderViewSet:
== f"/data/attributes/{error_pointer}"
)
@pytest.mark.parametrize(
"input_uid,stored_uid",
[
("Acme.okta.com", "acme.okta.com"),
(" ACME.OKTA.COM ", "acme.okta.com"),
("Agency.Okta-Gov.com", "agency.okta-gov.com"),
],
)
def test_providers_create_okta_uid_normalized(
self, authenticated_client, input_uid, stored_uid
):
response = authenticated_client.post(
reverse("provider-list"),
data={"provider": "okta", "uid": input_uid, "alias": "Okta"},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Provider.objects.get().uid == stored_uid
def test_providers_partial_update(self, authenticated_client, providers_fixture):
provider1, *_ = providers_fixture
new_alias = "This is the new name"
@@ -2320,17 +2372,17 @@ class TestProviderViewSet:
),
("alias", "aws_testing_1", 1),
("alias.icontains", "aws", 2),
("inserted_at", TODAY, 13),
("inserted_at", TODAY, 14),
(
"inserted_at.gte",
"2024-01-01",
13,
14,
),
("inserted_at.lte", "2024-01-01", 0),
(
"updated_at.gte",
"2024-01-01",
13,
14,
),
("updated_at.lte", "2024-01-01", 0),
]
@@ -2963,6 +3015,19 @@ class TestProviderSecretViewSet:
"api_token": "fake-vercel-api-token-for-testing",
},
),
# Okta with inline private key credentials
(
Provider.ProviderChoices.OKTA.value,
ProviderSecret.TypeChoices.STATIC,
{
"okta_client_id": "0oa123456789abcdef",
"okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
"okta_scopes": [
"okta.policies.read",
"okta.groups.read",
],
},
),
],
)
def test_provider_secrets_create_valid(
@@ -3075,6 +3140,46 @@ class TestProviderSecretViewSet:
== f"/data/attributes/{error_pointer}"
)
def test_provider_secrets_invalid_create_okta_missing_private_key(
self,
providers_fixture,
authenticated_client,
):
okta_provider = next(
provider
for provider in providers_fixture
if provider.provider == Provider.ProviderChoices.OKTA.value
)
data = {
"data": {
"type": "provider-secrets",
"attributes": {
"name": "Okta Secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"okta_client_id": "0oa123456789abcdef",
},
},
"relationships": {
"provider": {
"data": {"type": "providers", "id": str(okta_provider.id)}
}
},
}
}
response = authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["code"] == "required"
assert response.json()["errors"][0]["source"]["pointer"] == (
"/data/attributes/secret/okta_private_key"
)
def test_provider_secrets_partial_update(
self, authenticated_client, provider_secret_fixture
):
+20
View File
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
from prowler.providers.mongodbatlas.mongodbatlas_provider import (
MongodbatlasProvider,
)
from prowler.providers.okta.okta_provider import OktaProvider
from prowler.providers.openstack.openstack_provider import OpenstackProvider
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
from prowler.providers.vercel.vercel_provider import VercelProvider
@@ -93,6 +94,7 @@ def return_prowler_provider(
| KubernetesProvider
| M365Provider
| MongodbatlasProvider
| OktaProvider
| OpenstackProvider
| OraclecloudProvider
| VercelProvider
@@ -181,6 +183,10 @@ def return_prowler_provider(
from prowler.providers.vercel.vercel_provider import VercelProvider
prowler_provider = VercelProvider
case Provider.ProviderChoices.OKTA.value:
from prowler.providers.okta.okta_provider import OktaProvider
prowler_provider = OktaProvider
case _:
raise ValueError(f"Provider type {provider.provider} not supported")
return prowler_provider
@@ -246,6 +252,11 @@ def get_prowler_provider_kwargs(
**prowler_provider_kwargs,
"team_id": provider.uid,
}
elif provider.provider == Provider.ProviderChoices.OKTA.value:
prowler_provider_kwargs = {
**prowler_provider_kwargs,
"okta_org_domain": provider.uid,
}
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
# Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or
# a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest").
@@ -290,6 +301,7 @@ def initialize_prowler_provider(
| KubernetesProvider
| M365Provider
| MongodbatlasProvider
| OktaProvider
| OpenstackProvider
| OraclecloudProvider
| VercelProvider
@@ -351,6 +363,14 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
"raise_on_exception": False,
}
return prowler_provider.test_connection(**vercel_kwargs)
elif provider.provider == Provider.ProviderChoices.OKTA.value:
okta_kwargs = {
**prowler_provider_kwargs,
"okta_org_domain": provider.uid,
"provider_id": provider.uid,
"raise_on_exception": False,
}
return prowler_provider.test_connection(**okta_kwargs)
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
image_kwargs = {
"image": provider.uid,
@@ -404,6 +404,26 @@ from rest_framework_json_api import serializers
},
"required": ["clouds_yaml_content", "clouds_yaml_cloud"],
},
{
"type": "object",
"title": "Okta OAuth Credentials",
"properties": {
"okta_client_id": {
"type": "string",
"description": "Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.",
},
"okta_private_key": {
"type": "string",
"description": "PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.",
},
"okta_scopes": {
"type": "array",
"items": {"type": "string"},
"description": "OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.",
},
},
"required": ["okta_client_id", "okta_private_key"],
},
{
"type": "object",
"title": "Vercel API Token",
+11
View File
@@ -1543,6 +1543,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
serializer = GCPProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.GOOGLEWORKSPACE.value:
serializer = GoogleWorkspaceProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.OKTA.value:
serializer = OktaProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.GITHUB.value:
serializer = GithubProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.IAC.value:
@@ -1688,6 +1690,15 @@ class GoogleWorkspaceProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class OktaProviderSecret(serializers.Serializer):
okta_client_id = serializers.CharField()
okta_private_key = serializers.CharField()
okta_scopes = serializers.ListField(child=serializers.CharField(), required=False)
class Meta:
resource_name = "provider-secrets"
class MongoDBAtlasProviderSecret(serializers.Serializer):
atlas_public_key = serializers.CharField()
atlas_private_key = serializers.CharField()
+7
View File
@@ -571,6 +571,12 @@ def providers_fixture(tenants_fixture):
alias="vercel_testing",
tenant_id=tenant.id,
)
provider14 = Provider.objects.create(
provider="okta",
uid="acme.okta.com",
alias="okta_testing",
tenant_id=tenant.id,
)
return (
provider1,
@@ -586,6 +592,7 @@ def providers_fixture(tenants_fixture):
provider11,
provider12,
provider13,
provider14,
)
Generated
+1 -1
View File
@@ -4494,7 +4494,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.28.0"
version = "1.29.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -0,0 +1,30 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "prowler",
"version": "0.1.0",
"description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
"author": {
"name": "Prowler",
"email": "support@prowler.com",
"url": "https://prowler.com"
},
"homepage": "https://docs.prowler.com",
"repository": "https://github.com/prowler-cloud/prowler",
"license": "Apache-2.0",
"keywords": [
"prowler",
"security",
"compliance",
"cloud-security",
"mcp"
],
"userConfig": {
"api_key": {
"type": "string",
"title": "Prowler API key",
"description": "API key token used to authenticate with Prowler Cloud / Prowler App via the Prowler MCP server. Create one at https://cloud.prowler.com.",
"sensitive": true,
"required": true
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"prowler": {
"type": "http",
"url": "https://mcp.prowler.com/mcp",
"headers": {
"Authorization": "Bearer ${user_config.api_key}"
}
}
}
+80
View File
@@ -0,0 +1,80 @@
# Prowler for Claude Code
End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](https://docs.prowler.com/projects/prowler-mcp/). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
> **Preview**: this plugin is under active development. Report issues at <https://github.com/prowler-cloud/prowler/issues> or join the [Slack community](https://goto.prowler.com/slack).
## Requirements
- [Claude Code](https://www.claude.com/product/claude-code) installed and signed in.
- A [Prowler Cloud](https://cloud.prowler.com) account (the free tier is enough to start).
- A Prowler API key — create one at <https://cloud.prowler.com/profile>.
## Installation
Inside a Claude Code session:
```text
/plugin marketplace add prowler-cloud/prowler
/plugin install prowler@prowler-plugins
```
Or, if you already have the repo checked out locally:
```text
/plugin marketplace add /absolute/path/to/prowler
/plugin install prowler@prowler-plugins
```
## Configuration
On first install, Claude Code prompts for your **Prowler API key**. It is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again.
## Verify the install
In a Claude Code session:
```text
/mcp → "prowler" appears as a connected server
/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage
```
If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
## Usage
Open a conversation that mentions the framework you want to comply with. Examples:
- *"Make my AWS production account compliant with CIS 4.0."*
- *"Make my current Terraform project compliant with the Prowler ThreatScore Compliance Framework based on the latest scan results."*
- *"Help me get to 100% on PCI-DSS for this GCP project."*
You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
- **Claude-assisted** (default). Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
- **Claude autonomous**. Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
## Uninstalling
```text
/plugin uninstall prowler@prowler-plugins
/plugin marketplace remove prowler-plugins
```
The stored API key is removed automatically.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. |
| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. |
| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at <https://github.com/prowler-cloud/prowler>. |
## License
Apache 2.0 — see [LICENSE](../../LICENSE).
@@ -0,0 +1,199 @@
---
name: framework-compliance-triage
description: Make a cloud account compliant with a security or industry framework using Prowler Cloud.
---
# Framework compliance
Iterative, interactive flow that takes a cloud account through setup, reporting, and remediation until it complies with the chosen security or industry framework.
## Checkpoints
This skill uses **checkpoints** to mark moments where you must stop, post a clear question or summary to the user, and wait for the reply before continuing. Each checkpoint is rendered like this:
> **Checkpoint — <name>**
>
> What to present, and what to wait for.
Treat every checkpoint as a hard stop:
- Do not skip a checkpoint because the user previously said "go ahead", "just do it", or similar. Confirmations are scoped to a single checkpoint and do not transfer to later ones.
- Do not bundle two checkpoints into one message. Post one, wait for the reply, then continue.
- Do not infer the user's answer from context or proceed on silence. Ask explicitly and wait.
- If a checkpoint is conditional (e.g. only fires when multiple accounts exist), evaluate the condition first; if it does not apply, continue without prompting.
- If the user's initial message already answers the question a checkpoint asks (e.g. "make my AWS subscription compliant with CIS using Terraform autonomously"), treat the checkpoint as satisfied for the parts they covered, and only ask for what is still missing.
## 1. Initial Prowler Cloud setup
> **Checkpoint — Provider and framework selection**
>
> If the user has not already specified both the provider and the framework, ask explicitly and wait for the answer. If they have specified them in their opening message, skip this checkpoint.
Confirm both are supported by the Prowler Hub MCP:
- Enumerate supported providers with `prowler_hub_list_providers`.
- Enumerate frameworks for the chosen provider with `prowler_hub_list_compliances`, passing the provider `id` as the only element of the `provider` input list.
If the framework is not supported, tell the user, suggest they request it or contribute it themselves, and end the flow. Otherwise continue.
### 1.1 Connect to Prowler Cloud
Verify the Prowler MCP connection by calling `prowler_app_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation.
### 1.2 Verify the provider is configured (or configure it)
Call `prowler_app_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`.
- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`.
- **Provider present and configured.** Continue.
> **Checkpoint — Account selection** *(conditional: more than one account of the chosen provider is configured)*
>
> List the accounts with helpful detail (account name, uid, last scan date) and ask which one to use. Wait for the answer. If only one account exists, skip this checkpoint and use it.
### 1.3 Review compliance report for the provider account
The flow needs at least one completed scan with a compliance report available.
Look for a completed scan first: call `prowler_app_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_app_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
If no completed scan has a report, call `prowler_app_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)*
>
> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer.
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_app_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results.
## 2. Compliance report
Every iteration of the remediation loop reads and writes a single markdown file per provider account and framework, stored at `${CLAUDE_PROJECT_DIR}/.prowler/compliance-<compliance_id>-<provider_uid>.md`. Sanitize `<provider_uid>` to `[a-zA-Z0-9_-]` by replacing anything else with `-`. Create `.prowler/` if missing.
Across iterations, edit only: status tags on failed requirements and their findings, the per-requirement `Fix plan` / `Fix applied` sub-bullets added during sections 3.33.4, the **Global remediation approach** block, and the **Activity log** (append-only, newest on top). Requirement descriptions, finding IDs, and the entire **Manual review requirements** section are read-only after first render.
Status taxonomy for failed requirements and their findings:
- `[FAIL]` — failing in the latest scan.
- `[IN PROGRESS]` — picked up by section 3.3.
- `[FIXED-UNVERIFIED]` — remediation applied; not yet confirmed.
- `[PASS]` — passing in the latest scan (set when a rescan in section 3.5 confirms the fix).
- `[SKIPPED]` — user explicitly deferred.
### Report template
A fresh report is rendered like this (substituting values from the `prowler_app_get_compliance_framework_state_details` Prowler MCP tool response):
````markdown
# Compliance report: <compliance_id>
**Provider account**: <display name + uid>
**Scan ID**: <scan_id>
**Generated**: <ISO timestamp>
**Last update**: <ISO timestamp>
**Status**: <passed>/<total> passing (<pct>%) · <failed> failing · <manual_review> manual review
## Global remediation approach
<!-- Filled by section 3.1. -->
- **Primary tool**: _Terraform | Azure CLI | AWS CLI | web console | mixed_
- **Mode**: _Claude autonomous | Claude-assisted_
- **Notes**:
## Activity log
- <ISO timestamp> — Report initialized from scan `<scan_id>`.
## Failed requirements
### <code> — [FAIL]
**Description**: <text>
**Findings** (<n>):
- [FAIL] `<finding_id>`
## Manual review requirements
- **<code>** — [PENDING]: <description>
````
### 2.1 Generate or refresh the report
Resolve the report path for the current `compliance_id` and provider account.
If the file does not exist, call `prowler_app_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat.
> **Checkpoint — Report refresh** *(conditional: the file's `Scan ID` differs from the current target scan)*
>
> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer.
On confirmation, regenerate the failed-requirements section from the new `prowler_app_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list.
## 3. Remediation loop
### 3.1 Define the global remediation approach
Two modes are available:
- **Claude-assisted** (default when the user has not specified): per-requirement confirmation. For each requirement Claude shows the target resource, exact commands, side effects, and reversibility, then waits for explicit go-ahead before applying.
- **Claude autonomous**: no per-requirement gate, but Claude still presents one batch-level fix plan up front (§3.2) and waits for a single confirmation, and pauses if a finding looks not applicable, requires a paid feature, or has wide blast radius (breaks dev workflow, forces collaborator changes, is hard to reverse).
If the user phrases their request as "just do it" or similar, treat that as autonomous **with** the batch-plan confirmation still required — the confirmation is a property of the skill, not the user's verbosity preference.
> **Checkpoint — Global remediation approach**
>
> Ask the user which tool to use for fixes (Terraform, gh / az / aws CLI, web console, mixed...) and which mode to operate in. Wait for the answer before continuing. This checkpoint is non-negotiable: never assume a default tool, and never assume autonomous mode.
Once answered, write the values into the **Global remediation approach** block of the report file.
> **Checkpoint — Overwriting an existing approach** *(conditional: the block is already populated from a previous session)*
>
> Show the previous values and the new ones, and ask the user to confirm before overwriting. Wait for the answer.
### 3.2 Present the batch fix plan *(autonomous mode only)*
In **assisted** mode, skip this section — the per-requirement gate in §3.3 confirms each fix as it comes up. Only run §3.2 in **autonomous** mode, where the loop will otherwise apply fixes without further input.
Before touching anything, post a single chat summary covering every `[FAIL]` requirement:
- Group findings that share a fix (e.g. ten branch-protection requirements satisfied by one PUT call → present as one group).
- For each group: target resource, exact tool calls, side effects, reversibility.
- Call out findings that look **not applicable** to this target (e.g. an Organization-only check evaluated against a User account, a feature gated by a paid plan, a resource type the user doesn't have) and propose `[SKIPPED]` with the reason.
- Call out findings that require manual user action Claude cannot perform.
> **Checkpoint — Batch fix plan approval** *(conditional: autonomous mode)*
>
> Post the grouped plan and wait for explicit confirmation. Do not start any fix before the user replies.
Once approved, the loop proceeds through the batch without further prompts unless something deviates from the approved plan.
### 3.3 Pick the first FAIL requirement and inspect its findings
Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done.
Call `prowler_app_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2.
> **Checkpoint — Per-requirement approval** *(conditional: assisted mode)*
>
> Post the per-requirement plan in chat — resource, command, side effects, reversibility — and wait for confirmation before moving to §3.4. In **autonomous** mode, post the plan for transparency but proceed unless it deviates from the batch plan agreed in §3.2.
### 3.4 Diagnose, fix, verify
Read the remediation guidance returned in §3.3, identify the root cause, and apply the fix using the tool defined in the **Global remediation approach** block. After applying, verify via the same tool that applied the fix or via a provider API call when applicable. If the re-read shows the change did not land, leave the status at `[IN PROGRESS]`, surface the error to the user, and stop the loop for this requirement.
When the change is in place, append a `**Fix applied**: <tool, summary, refs>` sub-bullet to the requirement, move each fixed finding to `[FIXED-UNVERIFIED]`, and add one activity-log entry describing the change. If no programmatic verification was possible (e.g. web console action), note in the activity log that confirmation depends on the rescan in §3.5.
### 3.5 Loop
Move to the next `[FAIL]` requirement and repeat from section 3.3.
> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)*
>
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_app_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log.
+6
View File
@@ -73,6 +73,12 @@
"getting-started/products/prowler-lighthouse-ai"
]
},
{
"group": "Prowler for Claude Code",
"pages": [
"getting-started/products/prowler-claude-code-plugin"
]
},
{
"group": "Prowler MCP Server",
"pages": [
@@ -0,0 +1,101 @@
---
title: 'Prowler for Claude Code'
---
End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
<Warning>
**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback.
</Warning>
## Requirements
<CardGroup cols={3}>
<Card title="Claude Code" icon="terminal">
Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code).
</Card>
<Card title="Prowler Cloud account" icon="cloud">
The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
</Card>
<Card title="Prowler API key" icon="key">
Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile).
</Card>
</CardGroup>
## Installation
<Tabs>
<Tab title="From GitHub (recommended)">
Inside a Claude Code session:
```text
/plugin marketplace add prowler-cloud/prowler
/plugin install prowler@prowler-plugins
```
</Tab>
<Tab title="From a local clone">
If you already have the repository checked out:
```text
/plugin marketplace add /absolute/path/to/prowler
/plugin install prowler@prowler-plugins
```
</Tab>
</Tabs>
## Configuration
On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
<Note>
To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again.
</Note>
## Verify the installation
In a Claude Code session:
```text
/mcp → "prowler" appears as a connected server
/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage
```
If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
## Usage
Open a conversation that mentions the framework you want to comply with. Examples:
- *"Make my AWS production account compliant with CIS 4.0."*
- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."*
- *"Help me get to 100% on PCI-DSS for this GCP project."*
You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
<CardGroup cols={2}>
<Card title="Claude-assisted (default)" icon="hand">
Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
</Card>
<Card title="Claude autonomous" icon="robot">
Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
</Card>
</CardGroup>
Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
## Uninstalling
```text
/plugin uninstall prowler@prowler-plugins
/plugin marketplace remove prowler-plugins
```
The stored API key is removed automatically.
## Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. |
| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. |
| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
+2 -2
View File
@@ -32,10 +32,10 @@ Prowler supports a wide range of providers organized by category:
| [Azure](/user-guide/providers/azure/getting-started-azure) | Official | Subscriptions | UI, API, CLI |
| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI |
| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI |
| **NHN** | Unofficial | Tenants | CLI |
| **NHN** | [Contact us](https://prowler.com/contact) | Tenants | CLI |
| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI |
| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI |
| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) [Contact us](https://prowler.com/contact) | Unofficial | Organizations | CLI |
| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) | [Contact us](https://prowler.com/contact) | Organizations | CLI |
### Infrastructure as Code Providers
@@ -173,6 +173,215 @@ RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service
LIMIT 25
```
### Advanced Attack Path Scenarios
The following scenarios show how to compose graph traversals into real attack-path stories. Each query can be pasted directly into the custom query box: the API auto-scopes them to the selected provider and injects tenant/provider isolation, so there is no need to include account identifiers or `$provider_uid` in the text. All queries are openCypher v9 (Neo4j and Neptune compatible).
#### 1. Live attacker on the box that owns the keys
**Query story:** Finds an internet-exposed EC2 under an active GuardDuty SSH brute-force whose instance role can assume a higher-privileged role that can read a sensitive S3 bucket.
```cypher
MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance)
WHERE ec2.exposed_internet = true
MATCH p0 = (gd:GuardDutyFinding)-[:AFFECTS]->(ec2)
MATCH p1 = (ec2)-[:INSTANCE_PROFILE]->(prof:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(low:AWSRole)
MATCH p2 = (low)-[:STS_ASSUMEROLE_ALLOW]-(high:AWSRole)
MATCH p3 = (high)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2)
MATCH path_s3 = (acct)--(s3:S3Bucket)
WHERE high <> low
AND stmt.effect = 'Allow'
AND size([a IN stmt.action WHERE
toLower(a) STARTS WITH 's3:getobject'
OR toLower(a) STARTS WITH 's3:listbucket'
OR toLower(a) IN ['s3:*']
]) > 0
AND size([r IN stmt.resource WHERE
r CONTAINS s3.name
]) > 0
RETURN path_net, path_ec2, p0, p1, p2, p3, path_s3
```
**How it's built:**
- `path_ec2` anchors the graph on the account node and its internet-exposed EC2 instance, via a real account-to-resource edge. This is the visible spine that keeps everything connected.
- `p0` ties a `GuardDutyFinding` to that instance through the `AFFECTS` edge (the live SSH brute-force alert).
- `p1` walks the real graph edges from the instance to its instance profile to the role it runs as.
- `p2` follows the `STS_ASSUMEROLE_ALLOW` edge to the higher-privileged role the low role can assume. It is undirected so it works regardless of how the assume edge was ingested. `high <> low` stops a role matching itself.
- `p3` walks that role into its policy and policy statement.
- `path_net` is the optional `Internet -[:CAN_ACCESS]-> instance` edge. It makes "from the internet" literal on screen. Optional so a missing `Internet` node never breaks the query live.
- `path_s3` connects the sensitive bucket to the same account node, so it draws connected instead of floating. There is no physical edge from a role to a bucket; the grant is logical, enforced in the `WHERE`: the statement must allow an S3 read action (list comprehension over the `action` array) and its resource must cover the bucket (`CONTAINS s3.name`). The account is the shared hub; the bucket hanging off it next to the role chain is the teaching moment — the access exists only in IAM.
#### 2. Who can read the crown jewels
**Query story:** The sensitive bucket from the previous scenario seen from the data side: every role whose IAM policy can read it, regardless of how the role is reached.
```cypher
MATCH (s3:S3Bucket)
WHERE toLower(s3.name) CONTAINS 'sensitive'
MATCH (role:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND size([a IN stmt.action WHERE
toLower(a) STARTS WITH 's3:get'
OR toLower(a) STARTS WITH 's3:list'
OR toLower(a) IN ['s3:*']
]) > 0
AND size([r IN stmt.resource WHERE
r CONTAINS s3.name
]) > 0
WITH DISTINCT s3, role
LIMIT 25
MATCH path_s3 = (acct:AWSAccount)--(s3)
MATCH path_role = (acct)--(role)
RETURN path_s3, path_role
```
**How it's built:** data-centric, not attacker-centric — the same bucket the previous kill chain exfiltrates, approached from the other direction.
- The `S3Bucket` is bound first by name (one node), so everything else filters against it.
- `(role:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)` reaches statements only *through a role*, never via a global statement scan. A blanket `AWSPolicyStatement` scan also hits resource-policy statements whose shape differs and makes the list comprehension fail outright.
- The `WHERE` filters in place: an S3 read action plus a resource that names that bucket.
- `WITH DISTINCT s3, role LIMIT 25` collapses undirected-traversal duplicates and hard-caps the result.
- `path_s3` and `path_role` attach the account hubs only after the cap, against at most 25 rows, so the bucket and role(s) draw connected through the account instead of floating.
- No internet or EC2 here; this answers "who has the keys" instead of "how would an attacker get in."
#### 3. Lateral reach from an internet-exposed instance
**Query story:** The wide-angle view of the live-attacker scenario: every internet-exposed EC2, the role it runs as, and every role that role can assume. The first scenario is one specific exfiltration path inside this reach, under live attack.
```cypher
MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance)
WHERE ec2.exposed_internet = true
MATCH p1 = (ec2)-[:INSTANCE_PROFILE]->(prof:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(low:AWSRole)
MATCH p2 = (low)-[:STS_ASSUMEROLE_ALLOW]-(high:AWSRole)
OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2)
WHERE high <> low
RETURN path_net, path_ec2, p1, p2
```
**How it's built:** widens the lens instead of filtering down. It stops at the assume-role hop and shows every role reachable from any internet-exposed instance, without filtering down to a specific S3 leg.
- `path_ec2` is the account-to-instance spine.
- `p1` walks to the instance role.
- `p2` fans out to every role that role can assume.
- `path_net` adds the optional `Internet -[:CAN_ACCESS]->` edge.
- The first scenario is the specific exfiltration path under live attack; this is the broader privilege reach an attacker inherits the moment they land on the box.
#### 4. Role-chain privilege escalation
**Query story:** A pure-IAM escalation, no compromised instance: a role that can assume a second role whose policy lets it assume a third, admin-level role.
```cypher
MATCH path_root = (acct:AWSAccount)--(r1:AWSRole)
MATCH p1 = (r1)-[:STS_ASSUMEROLE_ALLOW]-(r2:AWSRole)
MATCH p2 = (r2)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
MATCH path_admin = (acct)--(admin:AWSRole)
WHERE r1 <> r2 AND r1 <> admin AND r2 <> admin
AND stmt.effect = 'Allow'
AND size([a IN stmt.action WHERE
toLower(a) IN ['sts:*', 'sts:assumerole']
]) > 0
AND size([res IN stmt.resource WHERE
res CONTAINS admin.name
]) > 0
RETURN path_root, p1, p2, path_admin
```
**How it's built:**
- `path_root` anchors role 1 to the account node, the spine that keeps the picture connected.
- `p1` is the one real assume edge in the chain (role 1 to role 2).
- `p2` walks role 2 into its policy and statement.
- `path_admin` connects the target admin role to the same account node so it draws connected. The third hop is not a graph edge: it exists only as `sts:AssumeRole` on that role's ARN inside the statement. The query proves it the same way the first scenario proves S3 access — the statement action must include an assume-role action and its resource list must reference the admin role's name.
- The three `<>` guards stop a role matching itself at any position.
#### 5. External identity trust map
**Query story:** Finds external identity providers (SSO, GitHub, GitLab, Terraform Cloud) and the AWS roles they are trusted to assume.
```cypher
MATCH p = (role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(idp:AWSPrincipal)
WHERE idp.arn CONTAINS 'saml-provider'
OR idp.arn CONTAINS 'oidc-provider'
MATCH path_role = (acct:AWSAccount)--(role)
RETURN p, path_role
```
**How it's built:** federated principals are stored as `AWSPrincipal` nodes whose ARN contains `saml-provider` (SSO) or `oidc-provider` (GitHub, GitLab, Terraform Cloud).
- `p` matches the trust edge undirected. It is written `(AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(AWSPrincipal)`, role to principal, so a directed `principal -> role` match returns nothing; undirected matches regardless of ingest direction.
- The `WHERE` keeps only SAML or OIDC providers, drawing a fan-out from each external identity provider to every role it can assume (including reserved SSO admin roles).
- `path_role` ties every trusted role to the account node so the provider stars share one spine instead of drawing as separate islands.
#### 6. Federated SSO roles flagged as admin or privesc
**Query story:** The dangerous subset of the trust map above — externally-federated SSO roles that Prowler also flags for AdministratorAccess or privilege escalation.
```cypher
MATCH (idp:AWSPrincipal)-[:TRUSTS_AWS_PRINCIPAL]-(role:AWSRole)
WHERE idp.arn CONTAINS 'saml-provider'
OR idp.arn CONTAINS 'oidc-provider'
MATCH (role)-[:HAS_FINDING]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
AND pf.check_id IN [
'iam_inline_policy_allows_privilege_escalation',
'iam_role_administratoraccess_policy',
'iam_inline_policy_no_administrative_privileges',
'iam_user_administrator_access_policy'
]
WITH DISTINCT idp, role, pf
LIMIT 60
MATCH path_root = (acct:AWSAccount)--(role)
MATCH p_trust = (idp)-[:TRUSTS_AWS_PRINCIPAL]-(role)
MATCH p_find = (role)-[:HAS_FINDING]-(pf)
RETURN path_root, p_trust, p_find
```
**How it's built:** a plain "list every flagged identity" query is a wide fan that draws as a column, and `ProwlerFinding` nodes accumulate across scans with no scan filter available in custom queries.
- The first MATCH plus `WHERE` keeps only roles trusted by a SAML or OIDC provider (trust edge undirected, so direction does not matter).
- The second MATCH plus `check_id IN [...]` keeps only those carrying one of the four privilege-escalation or admin checks.
- `WITH DISTINCT ... LIMIT 60` collapses duplicate finding nodes and hard-caps the result.
- `p_trust`, `p_find`, and `path_root` draw it connected three ways: provider to role through the trust edge, role to its finding, and role to the account.
- The previous scenario shows who can walk in; this shows which of those roles Prowler already flags as over-privileged.
#### 7. World-readable S3 buckets
**Query story:** Unlike the IAM-gated sensitive bucket in scenarios 1 and 2, these buckets are open to anyone on the internet with no credentials at all.
```cypher
MATCH path_s3 = (acct:AWSAccount)--(s3:S3Bucket)
WHERE s3.anonymous_access = true
OPTIONAL MATCH p = (s3)--(stmt:S3PolicyStatement)
RETURN path_s3, p
```
**How it's built:** the counterpoint to scenarios 1 and 2 — there the sensitive bucket is reachable only through an IAM role chain; here the bucket needs no role at all.
- `path_s3` connects each public bucket to its account node so they draw connected. Cartography sets `anonymous_access = true` when a bucket's policy or ACL allows public access.
- `p` is an optional match that pulls in the `S3PolicyStatement` granting the access where one exists, so the public grant is visible next to the bucket. Buckets that are public via ACL only still show, connected to the account.
#### 8. Internet exposure surface
**Query story:** The raw external attack surface behind scenarios 1 and 3: every internet-exposed EC2 instance with its security groups and the exact inbound ports left open.
```cypher
MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance)
WHERE ec2.exposed_internet = true
MATCH p1 = (ec2)--(sg:EC2SecurityGroup)--(rule:IpPermissionInbound)
OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2)
OPTIONAL MATCH p2 = (ec2)-[:INSTANCE_PROFILE]->(:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(:AWSRole)
RETURN path_net, path_ec2, p1, p2
```
**How it's built:** `exposed_internet = true` is Cartography's computed reachability flag.
- `path_ec2` hubs all exposed instances on the account node so they draw as one picture.
- `p1` joins each instance to its security groups and inbound rules so the open ports are on screen.
- `path_net` adds the optional `Internet -[:CAN_ACCESS]->` edge so the external reachability is explicit.
- `p2` optionally adds the instance role, which connects this surface view back to the kill chains in scenarios 1 and 3.
### Tips for Writing Queries
- Start small with `LIMIT` to inspect the shape of the data before broadening the pattern.
@@ -67,7 +67,14 @@ The currently active organization is indicated by an **Active** badge. Switching
## Editing an Organization Name
Organization owners with the **Manage Account** permission can rename an organization:
Renaming an organization requires **both** of the following conditions to be met:
* The user's **membership role** in that organization must be `owner` (visible as the `owner` badge in the Organizations card).
* The user must have a role that grants the **Manage Account** permission.
Users who only meet one of the two conditions will not see the **Edit** button. For example, a user whose membership role is `member` will not see the **Edit** button even if their role grants `Manage Account`.
To rename an organization:
1. Navigate to the **Profile** page.
@@ -175,6 +182,6 @@ If the expelled organization was the user's **only** organization, the account i
| View organizations | Any authenticated user |
| Create an organization | Any authenticated user |
| Switch organizations | Any authenticated user |
| Edit organization name | Organization owner with **Manage Account** permission |
| Delete an organization | Organization owner with **Manage Account** permission; must belong to more than one organization |
| Edit organization name | Membership role `owner` **and** a role with **Manage Account** permission |
| Delete an organization | Membership role `owner` **and** a role with **Manage Account** permission; must belong to more than one organization |
| Expel a user from an organization | Organization owner (no additional permission required); last remaining owner cannot expel themselves |
+30
View File
@@ -0,0 +1,30 @@
# osv-scanner per-vulnerability ignore list.
#
# Each [[IgnoredVulns]] entry must include a `reason` explaining why the
# finding is accepted and an `ignoreUntil` date so the suppression auto-expires
# and gets re-evaluated. See https://github.com/google/osv-scanner for the
# config schema.
[[IgnoredVulns]]
id = "PYSEC-2025-183"
ignoreUntil = 2026-08-20T00:00:00Z
reason = """
CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes
weak encryption, but the underlying issue is that callers may pick a short
HMAC secret key-length enforcement is the application's responsibility, not
a defect in the library. We are on pyjwt 2.12.1 (latest at pin time) and
enforce key strength in our own auth code, so this advisory does not apply.
Re-evaluate when a non-disputed advisory or upstream fix lands.
"""
[[IgnoredVulns]]
id = "PYSEC-2026-89"
ignoreUntil = 2026-08-20T00:00:00Z
reason = """
False positive caused by a malformed PYSEC record. The equivalent GitHub
Security Advisory (GHSA-5wmx-573v-2qwq) for CVE-2025-69534 declares the issue
fixed in markdown 3.8.1. We are on markdown==3.10.2 (latest release, includes
the fix), but the PYSEC entry's range is [{introduced: "0"}, {}] with no
closing "fixed" event, so osv-scanner flags every version. There is no newer
release to upgrade to. Re-evaluate once the PYSEC record is corrected upstream.
"""
+7
View File
@@ -8,8 +8,15 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `entra_app_registration_client_secret_unused` check for M365 provider [(#11232)](https://github.com/prowler-cloud/prowler/pull/11232)
- `cloudsql_instance_cmek_encryption_enabled` check for GCP provider [(#11023)](https://github.com/prowler-cloud/prowler/pull/11023)
- Google Workspace Groups service with 3 new checks [(#11186)](https://github.com/prowler-cloud/prowler/pull/11186)
- `ses_identity_dkim_enabled` check for AWS provider [(#10923)](https://github.com/prowler-cloud/prowler/pull/10923)
- `sagemaker_models_registry_in_use` check for AWS provider, verifying that at least one SageMaker Model Package Group has an approved model package to enforce ML governance workflows [(#11196)](https://github.com/prowler-cloud/prowler/pull/11196)
- `signon_dod_warning_banner_configured`, `signon_global_session_lifetime_18h`, `signon_global_session_cookies_not_persistent` and `signon_global_session_policy_network_zone_enforced` checks for Okta provider [(#11224)](https://github.com/prowler-cloud/prowler/pull/11224)
### 🔄 Changed
- `OktaProvider.test_connection` accepts an optional `provider_id` (org domain) and raises `OktaInvalidProviderIdError` (14007) when it doesn't match the authenticated org — guards against stored UID drifting from the credentials' org [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184)
---
## [5.27.1] (Prowler UNRELEASED)
@@ -1222,7 +1222,9 @@
{
"Id": "3.1.6.1",
"Description": "Ensure accessing groups from outside this organization is set to private",
"Checks": [],
"Checks": [
"groups_external_access_restricted"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1243,7 +1245,9 @@
{
"Id": "3.1.6.2",
"Description": "Ensure creating groups is restricted",
"Checks": [],
"Checks": [
"groups_creation_restricted"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1264,7 +1268,9 @@
{
"Id": "3.1.6.3",
"Description": "Ensure default for permission to view conversations is restricted",
"Checks": [],
"Checks": [
"groups_view_conversations_restricted"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1626,7 +1626,9 @@
{
"Id": "GWS.GROUPS.1.1",
"Description": "Group access from outside the organization SHALL be disabled unless explicitly granted by the group owner",
"Checks": [],
"Checks": [
"groups_external_access_restricted"
],
"Attributes": [
{
"Section": "Groups",
@@ -1639,7 +1641,9 @@
{
"Id": "GWS.GROUPS.1.2",
"Description": "Group owners' ability to add external members to groups SHOULD be disabled unless necessary for agency mission fulfillment",
"Checks": [],
"Checks": [
"groups_creation_restricted"
],
"Attributes": [
{
"Section": "Groups",
@@ -1652,7 +1656,9 @@
{
"Id": "GWS.GROUPS.1.3",
"Description": "Group owners' ability to allow posting to a group by an external, non-group member SHOULD be disabled unless necessary for agency mission fulfillment",
"Checks": [],
"Checks": [
"groups_creation_restricted"
],
"Attributes": [
{
"Section": "Groups",
@@ -1665,7 +1671,9 @@
{
"Id": "GWS.GROUPS.2.1",
"Description": "Group creation SHOULD be restricted to admins within the organization unless necessary for agency mission fulfillment",
"Checks": [],
"Checks": [
"groups_creation_restricted"
],
"Attributes": [
{
"Section": "Groups",
@@ -1678,7 +1686,9 @@
{
"Id": "GWS.GROUPS.3.1",
"Description": "The default permission to view conversations SHOULD be set to All Group Members",
"Checks": [],
"Checks": [
"groups_view_conversations_restricted"
],
"Attributes": [
{
"Section": "Groups",
@@ -0,0 +1,42 @@
{
"Provider": "aws",
"CheckID": "sagemaker_models_registry_in_use",
"CheckTitle": "Amazon SageMaker Model Registry should have at least one approved model package",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "sagemaker",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**SageMaker Model Registry** is evaluated to verify that at least one Model Package Group exists and contains at least one model package with **ModelApprovalStatus = Approved**. This confirms that the ML governance workflow (register → review → approve → deploy) is actively in use.",
"Risk": "An empty Model Registry, or one with no approved packages, indicates that models are being deployed outside any review process. This breaks provenance and accountability for production ML workloads, making it impossible to enforce governance controls such as auditing, versioning, and approval workflows.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry.html",
"https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry-approve.html",
"https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_ListModelPackageGroups.html",
"https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_ListModelPackages.html"
],
"Remediation": {
"Code": {
"CLI": "aws sagemaker list-model-package-groups\naws sagemaker list-model-packages --model-package-group-name <group-name>\naws sagemaker update-model-package --model-package-arn <arn> --model-approval-status Approved",
"NativeIaC": "",
"Other": "1. In the AWS console, navigate to SageMaker > Models > Model Registry.\n2. Create a Model Package Group if none exists.\n3. Register a model version in the group.\n4. Review and approve at least one model package by setting its approval status to Approved.",
"Terraform": ""
},
"Recommendation": {
"Text": "Register all production models in the **SageMaker Model Registry** and enforce an approval workflow before deployment. Ensure at least one model package per group reaches **Approved** status. Use **IAM policies** to restrict who can approve model packages and integrate with **CI/CD pipelines** to automate registration.",
"Url": "https://hub.prowler.com/check/sagemaker_models_registry_in_use"
}
},
"Categories": [
"gen-ai",
"software-supply-chain"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,28 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client
class sagemaker_models_registry_in_use(Check):
"""Ensure that SageMaker Model Registry has at least one approved model package."""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports indicating whether the SageMaker Model Registry
in each region contains at least one approved model package.
"""
findings = []
for registry in sagemaker_client.sagemaker_model_registries:
report = Check_Report_AWS(metadata=self.metadata(), resource=registry)
if not registry.has_groups:
report.status = "FAIL"
report.status_extended = f"SageMaker Model Registry in region {registry.region} has no Model Package Groups."
elif registry.has_approved_packages:
report.status = "PASS"
report.status_extended = f"SageMaker Model Registry in region {registry.region} has at least one approved model package."
else:
report.status = "FAIL"
report.status_extended = f"SageMaker Model Registry in region {registry.region} has Model Package Groups but no approved model packages."
findings.append(report)
return findings
@@ -17,6 +17,7 @@ class SageMaker(AWSService):
self.sagemaker_training_jobs = []
self.sagemaker_domains = []
self.endpoint_configs = {}
self.sagemaker_model_registries = []
# Retrieve resources concurrently
self.__threading_call__(self._list_notebook_instances)
@@ -24,6 +25,7 @@ class SageMaker(AWSService):
self.__threading_call__(self._list_training_jobs)
self.__threading_call__(self._list_endpoint_configs)
self.__threading_call__(self._list_domains)
self.__threading_call__(self._list_model_package_groups)
# Describe resources concurrently
self.__threading_call__(self._describe_model, self.sagemaker_models)
@@ -207,6 +209,71 @@ class SageMaker(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_model_package_groups(self, regional_client):
logger.info("SageMaker - listing model package groups...")
registry_arn = self.get_unknown_arn(
region=regional_client.region,
resource_type="model-registry",
)
has_groups = False
has_approved = False
try:
paginator = regional_client.get_paginator("list_model_package_groups")
for page in paginator.paginate():
for group in page["ModelPackageGroupSummaryList"]:
has_groups = True
if not has_approved:
group_name = group["ModelPackageGroupName"]
try:
pkg_paginator = regional_client.get_paginator(
"list_model_packages"
)
for pkg_page in pkg_paginator.paginate(
ModelPackageGroupName=group_name,
ModelApprovalStatus="Approved",
):
if pkg_page["ModelPackageSummaryList"]:
has_approved = True
break
except ClientError as pkg_error:
if pkg_error.response["Error"]["Code"] in (
"AccessDeniedException",
"UnrecognizedClientException",
):
raise
logger.error(
f"{regional_client.region} -- {pkg_error.__class__.__name__}[{pkg_error.__traceback__.tb_lineno}]: {pkg_error}"
)
except Exception as pkg_error:
logger.error(
f"{regional_client.region} -- {pkg_error.__class__.__name__}[{pkg_error.__traceback__.tb_lineno}]: {pkg_error}"
)
except ClientError as error:
if error.response["Error"]["Code"] in (
"AccessDeniedException",
"UnrecognizedClientException",
):
logger.warning(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.sagemaker_model_registries.append(
ModelRegistry(
name="SageMaker Model Registry",
arn=registry_arn,
region=regional_client.region,
has_groups=has_groups,
has_approved_packages=has_approved,
)
)
def _list_tags_for_resource(self, resource):
"""
Lists tags for a specific SageMaker resource.
@@ -364,3 +431,13 @@ class EndpointConfig(BaseModel):
arn: str
production_variants: list[ProductionVariant] = []
tags: Optional[list] = []
class ModelRegistry(BaseModel):
"""Represents the SageMaker Model Registry state for a specific region."""
name: str
arn: str
region: str
has_groups: bool = False
has_approved_packages: bool = False
@@ -0,0 +1,40 @@
{
"Provider": "aws",
"CheckID": "ses_identity_dkim_enabled",
"CheckTitle": "SES identity has DKIM signing enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "ses",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"ResourceGroup": "messaging",
"Description": "**Amazon SES identities** are evaluated for **DKIM (DomainKeys Identified Mail)** signing enabled and verified. DKIM adds a cryptographic signature to outgoing emails, allowing recipients to verify that the email was sent by the domain owner and was not altered in transit.",
"Risk": "Without DKIM signing, emails sent from SES identities are vulnerable to **spoofing and tampering**. Attackers can forge emails that appear to come from your domain, leading to phishing attacks, brand impersonation, and loss of email deliverability. Email providers are more likely to reject or mark unsigned emails as spam, impacting business communication and reputation.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim.html",
"https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication.html"
],
"Remediation": {
"Code": {
"CLI": "aws sesv2 put-email-identity-dkim-signing-attributes --email-identity <IDENTITY-NAME> --signing-attributes-origin AWS_SES",
"NativeIaC": "",
"Other": "1. In the AWS Console, go to Simple Email Service (SES)\n2. Open Verified identities and select the affected identity\n3. Click the Authentication tab\n4. Under DKIM, click Edit\n5. Enable DKIM signatures and select 'Provide DKIM authentication token (Easy DKIM)'\n6. Save changes and add the provided CNAME records to your DNS provider",
"Terraform": "```hcl\nresource \"aws_ses_domain_dkim\" \"<example_resource_name>\" {\n domain = \"<example_domain_name>\"\n}\n\n# Add the CNAME records to Route53 (or your DNS provider)\nresource \"aws_route53_record\" \"<example_resource_name>_dkim\" {\n count = 3\n zone_id = \"<route53_zone_id>\"\n name = \"${aws_ses_domain_dkim.<example_resource_name>.dkim_tokens[count.index]}._domainkey.<example_domain_name>\"\n type = \"CNAME\"\n ttl = 600\n records = [\"${aws_ses_domain_dkim.<example_resource_name>.dkim_tokens[count.index]}.dkim.amazonses.com\"]\n}\n```"
},
"Recommendation": {
"Text": "Enable **DKIM signing** for all SES identities and ensure the DKIM status is **SUCCESS**. Add the required CNAME records to your DNS provider to complete verification. Combine DKIM with **SPF** and **DMARC** for comprehensive email authentication following **defense in depth** principles. Monitor DKIM status regularly and rotate DKIM keys as recommended by AWS.",
"Url": "https://hub.prowler.com/check/ses_identity_dkim_enabled"
}
},
"Categories": [
"identity-access",
"email-security"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,33 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.ses.ses_client import ses_client
class ses_identity_dkim_enabled(Check):
def execute(self):
findings = []
for identity in ses_client.email_identities.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=identity)
if identity.dkim_status == "SUCCESS" and identity.dkim_signing_enabled:
report.status = "PASS"
report.status_extended = f"SES identity {identity.name} has DKIM signing enabled and verified."
elif identity.dkim_status in (
"PENDING",
"NOT_STARTED",
"TEMPORARY_FAILURE",
):
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} has DKIM signing not verified (status: {identity.dkim_status})."
elif identity.dkim_status == "FAILED":
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} has DKIM signing failed verification."
elif (
identity.dkim_status == "SUCCESS" and not identity.dkim_signing_enabled
):
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} has DKIM verified but signing is disabled."
else:
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} does not have DKIM signing configured."
findings.append(report)
return findings
@@ -46,9 +46,15 @@ class SES(AWSService):
identity_attributes = regional_client.get_email_identity(
EmailIdentity=identity.name
)
for _, content in identity_attributes["Policies"].items():
for _, content in identity_attributes.get("Policies", {}).items():
identity.policy = loads(content)
identity.tags = identity_attributes["Tags"]
identity.tags = identity_attributes.get("Tags", [])
dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {}
identity.dkim_status = dkim_attrs.get("Status")
identity.dkim_signing_enabled = dkim_attrs.get("SigningEnabled", False)
identity.dkim_signing_attributes_origin = dkim_attrs.get(
"SigningAttributesOrigin"
)
except Exception as error:
logger.error(
@@ -67,3 +73,6 @@ class Identity(BaseModel):
type: Optional[str]
policy: Optional[dict] = None
tags: Optional[list] = []
dkim_status: Optional[str] = None
dkim_signing_attributes_origin: Optional[str] = None
dkim_signing_enabled: Optional[bool] = False
@@ -0,0 +1,6 @@
from prowler.providers.common.provider import Provider
from prowler.providers.googleworkspace.services.groups.groups_service import (
Groups,
)
groups_client = Groups(Provider.get_global_provider())
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "groups_creation_restricted",
"CheckTitle": "Group creation is restricted to admins with no external members or incoming email",
"CheckType": [],
"ServiceName": "groups",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Google Groups for Business **creation settings** control who can create groups and whether group owners can add external members or allow incoming email from outside the organization. Restricting creation to admins and disabling external member and incoming email options limits the attack surface.",
"Risk": "Allowing any user to create groups with external members or incoming email from outside increases the risk of **unauthorized data sharing**, **spam delivery**, and **shadow IT** groups that bypass organizational controls.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Creating groups**\n4. Select **Only organization admins can create groups**\n5. Uncheck **Group owners can allow external members**\n6. Uncheck **Group owners can allow incoming email from outside the organization**\n7. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Restrict **group creation** to **organization admins** and disable **external member** and **incoming email** options to maintain control over group membership and communication boundaries.",
"Url": "https://hub.prowler.com/check/groups_creation_restricted"
}
},
"Categories": [
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [
"groups_external_access_restricted",
"groups_view_conversations_restricted"
],
"Notes": ""
}
@@ -0,0 +1,76 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.groups.groups_client import (
groups_client,
)
class groups_creation_restricted(Check):
"""Check that group creation is restricted to admins only with no external members or incoming email.
This check verifies three sub-settings:
- Only organization admins can create groups (not all users)
- Group owners cannot allow external members
- Group owners cannot allow incoming email from outside the organization
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if groups_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=groups_client.policies,
resource_id="groupsPolicies",
resource_name="Groups Policies",
customer_id=groups_client.provider.identity.customer_id,
)
policies = groups_client.policies
domain = groups_client.provider.identity.domain
access_level = policies.create_groups_access_level
external_members = policies.owners_can_allow_external_members
incoming_mail = policies.owners_can_allow_incoming_mail_from_public
issues = []
# Check creation access level
# Default is USERS_IN_DOMAIN (insecure) — only ADMIN_ONLY is compliant
if access_level is None or access_level != "ADMIN_ONLY":
effective = access_level or "USERS_IN_DOMAIN (default)"
issues.append(
f"group creation is set to {effective} instead of ADMIN_ONLY"
)
# Check external members
# Default is false (secure) — only false is compliant
if external_members is True:
issues.append("group owners can allow external members")
# Check incoming mail from outside
# Default is false (secure) — only true is non-compliant
if incoming_mail is True:
issues.append(
"group owners can allow incoming email from outside the organization"
)
if not issues:
report.status = "PASS"
report.status_extended = (
f"Group creation is properly restricted in domain {domain}: "
f"admin-only creation, no external members, "
f"no incoming email from outside."
)
else:
report.status = "FAIL"
issues_text = "; ".join(issues)
report.status_extended = (
f"Group creation is not fully restricted "
f"in domain {domain}: {issues_text}."
)
findings.append(report)
return findings
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "groups_external_access_restricted",
"CheckTitle": "Accessing groups from outside the organization is set to private",
"CheckType": [],
"ServiceName": "groups",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Google Groups for Business **external access** controls whether people outside the organization can view and search for groups. When set to private, only users within the domain can discover and access groups, while external users can still email a group if the group's own settings allow it.",
"Risk": "Allowing external access to groups exposes **group names, descriptions, and membership** to anyone outside the organization, increasing the risk of **information disclosure** and enabling external parties to identify targets for **social engineering attacks**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Sharing options**\n4. Set **Accessing groups from outside this organization** to **Private**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Set **external group access** to **private** to prevent external parties from viewing or searching for organizational groups.",
"Url": "https://hub.prowler.com/check/groups_external_access_restricted"
}
},
"Categories": [
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [
"groups_creation_restricted",
"groups_view_conversations_restricted"
],
"Notes": ""
}
@@ -0,0 +1,54 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.groups.groups_client import (
groups_client,
)
class groups_external_access_restricted(Check):
"""Check that accessing groups from outside the organization is set to private.
This check verifies that the domain-level Groups for Business policy
restricts external access so that only domain users can view groups,
preventing information exposure to external parties.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if groups_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=groups_client.policies,
resource_id="groupsPolicies",
resource_name="Groups Policies",
customer_id=groups_client.provider.identity.customer_id,
)
collaboration = groups_client.policies.collaboration_capability
domain = groups_client.provider.identity.domain
if collaboration == "DOMAIN_USERS_ONLY":
report.status = "PASS"
report.status_extended = (
f"Groups external access is set to private (domain users only) "
f"in domain {domain}."
)
elif collaboration is None:
report.status = "PASS"
report.status_extended = (
f"Groups external access uses Google's secure default "
f"configuration (private) in domain {domain}."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Groups external access is set to {collaboration} "
f"in domain {domain}. "
f"External access should be set to private (domain users only)."
)
findings.append(report)
return findings
@@ -0,0 +1,118 @@
from typing import Optional
from pydantic import BaseModel
from prowler.lib.logger import logger
from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService
class Groups(GoogleWorkspaceService):
"""Google Workspace Groups for Business service for auditing domain-level group policies.
Uses the Cloud Identity Policy API v1 to read group sharing, creation,
and conversation viewing settings configured in the Admin Console.
"""
def __init__(self, provider):
super().__init__(provider)
self.policies = GroupsPolicies()
self.policies_fetched = False
self._fetch_groups_for_business_policies()
def _fetch_groups_for_business_policies(self):
"""Fetch Groups for Business policies from the Cloud Identity Policy API v1."""
logger.info("Groups for Business - Fetching policies...")
try:
service = self._build_service("cloudidentity", "v1")
if not service:
logger.error("Failed to build Cloud Identity service")
return
request = service.policies().list(
pageSize=100,
filter='setting.type.matches("groups_for_business.*")',
)
fetch_succeeded = True
while request is not None:
try:
response = request.execute()
for policy in response.get("policies", []):
if not self._is_customer_level_policy(policy):
continue
setting = policy.get("setting", {})
setting_type = setting.get("type", "").removeprefix("settings/")
logger.debug(f"Processing setting type: {setting_type}")
value = setting.get("value", {})
if setting_type == "groups_for_business.groups_sharing":
self.policies.collaboration_capability = value.get(
"collaborationCapability"
)
self.policies.create_groups_access_level = value.get(
"createGroupsAccessLevel"
)
self.policies.owners_can_allow_external_members = value.get(
"ownersCanAllowExternalMembers"
)
self.policies.owners_can_allow_incoming_mail_from_public = (
value.get("ownersCanAllowIncomingMailFromPublic")
)
self.policies.view_topics_default_access_level = value.get(
"viewTopicsDefaultAccessLevel"
)
self.policies.owners_can_hide_groups = value.get(
"ownersCanHideGroups"
)
self.policies.new_groups_are_hidden = value.get(
"newGroupsAreHidden"
)
logger.debug(
"Groups for Business sharing settings fetched."
)
request = service.policies().list_next(request, response)
except Exception as error:
self._handle_api_error(
error,
"fetching Groups for Business policies",
self.provider.identity.customer_id,
)
fetch_succeeded = False
break
self.policies_fetched = fetch_succeeded
logger.info(
f"Groups for Business policies fetched - "
f"Collaboration: {self.policies.collaboration_capability}, "
f"Creation: {self.policies.create_groups_access_level}, "
f"View topics: {self.policies.view_topics_default_access_level}"
)
except Exception as error:
self._handle_api_error(
error,
"fetching Groups for Business policies",
self.provider.identity.customer_id,
)
self.policies_fetched = False
class GroupsPolicies(BaseModel):
"""Model for domain-level Groups for Business policy settings."""
# groups_for_business.groups_sharing
collaboration_capability: Optional[str] = None
create_groups_access_level: Optional[str] = None
owners_can_allow_external_members: Optional[bool] = None
owners_can_allow_incoming_mail_from_public: Optional[bool] = None
view_topics_default_access_level: Optional[str] = None
owners_can_hide_groups: Optional[bool] = None
new_groups_are_hidden: Optional[bool] = None
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "groups_view_conversations_restricted",
"CheckTitle": "Default permission to view conversations is set to all group members",
"CheckType": [],
"ServiceName": "groups",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Google Groups for Business **conversation viewing** controls who can see group conversations by default. Restricting this to group members only ensures that conversations are visible only to participants, not all users in the organization or external parties.",
"Risk": "Allowing all organization users or anyone to view group conversations can lead to **information disclosure** of sensitive discussions, internal decisions, and confidential data shared within groups.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Sharing options**\n4. Set **Default for permission to view conversations** to **All group members**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Set the default **permission to view conversations** to **all group members** to ensure group discussions are only visible to their participants.",
"Url": "https://hub.prowler.com/check/groups_view_conversations_restricted"
}
},
"Categories": [
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [
"groups_external_access_restricted",
"groups_creation_restricted"
],
"Notes": ""
}
@@ -0,0 +1,55 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.groups.groups_client import (
groups_client,
)
class groups_view_conversations_restricted(Check):
"""Check that the default permission to view conversations is set to All Group Members.
This check verifies that the domain-level Groups for Business policy
restricts conversation viewing to group members only, preventing
broader access by all organization users or anyone.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if groups_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=groups_client.policies,
resource_id="groupsPolicies",
resource_name="Groups Policies",
customer_id=groups_client.provider.identity.customer_id,
)
view_access = groups_client.policies.view_topics_default_access_level
domain = groups_client.provider.identity.domain
if view_access == "GROUP_MEMBERS":
report.status = "PASS"
report.status_extended = (
f"Default permission to view conversations is set to "
f"all group members in domain {domain}."
)
elif view_access is None:
report.status = "FAIL"
report.status_extended = (
f"Default permission to view conversations uses Google's default "
f"configuration (all organization users) in domain {domain}. "
f"It should be restricted to all group members only."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Default permission to view conversations is set to "
f"{view_access} in domain {domain}. "
f"It should be restricted to all group members only."
)
findings.append(report)
return findings
@@ -34,6 +34,10 @@ class OktaBaseException(ProwlerException):
"message": "Okta service app is missing required scopes",
"remediation": "Have a Super Admin grant the required *.read scopes to the service app and assign the Read-Only Administrator role.",
},
(14007, "OktaInvalidProviderIdError"): {
"message": "The provided provider_id does not match the credentials org domain",
"remediation": "Check the provider_id (Okta org domain) and ensure it matches the org the service app credentials were issued for.",
},
}
def __init__(self, code, file=None, original_exception=None, message=None):
@@ -110,3 +114,10 @@ class OktaInsufficientPermissionsError(OktaCredentialsError):
super().__init__(
14006, file=file, original_exception=original_exception, message=message
)
class OktaInvalidProviderIdError(OktaCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
14007, file=file, original_exception=original_exception, message=message
)
+22 -2
View File
@@ -22,6 +22,7 @@ from prowler.providers.okta.exceptions.exceptions import (
OktaInsufficientPermissionsError,
OktaInvalidCredentialsError,
OktaInvalidOrgDomainError,
OktaInvalidProviderIdError,
OktaPrivateKeyFileError,
OktaSetUpIdentityError,
OktaSetUpSessionError,
@@ -348,8 +349,17 @@ class OktaProvider(Provider):
okta_private_key_file: str = "",
okta_scopes: Optional[Union[str, list[str]]] = None,
raise_on_exception: bool = True,
provider_id: str = None,
) -> Connection:
"""Test the connection to Okta with the provided OAuth credentials."""
"""Test the connection to Okta with the provided OAuth credentials.
Args:
provider_id: The provider ID (Okta org domain). When supplied, the
authenticated org domain must match it guards against the
stored provider UID drifting from the org the credentials were
actually issued for. Compared case-insensitively, matching the
normalization applied during session setup.
"""
try:
OktaProvider.validate_arguments(
okta_org_domain=okta_org_domain,
@@ -364,7 +374,17 @@ class OktaProvider(Provider):
private_key_file=okta_private_key_file,
scopes=okta_scopes,
)
OktaProvider.setup_identity(session)
identity = OktaProvider.setup_identity(session)
if provider_id and provider_id.strip().lower() != identity.org_domain:
raise OktaInvalidProviderIdError(
file=os.path.basename(__file__),
message=(
f"The provider ID '{provider_id}' does not match the "
f"authenticated Okta org domain '{identity.org_domain}'."
),
)
return Connection(is_connected=True)
except Exception as error:
logger.critical(
@@ -0,0 +1,156 @@
from unittest import mock
from prowler.providers.aws.services.sagemaker.sagemaker_service import ModelRegistry
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
set_mocked_aws_provider,
)
registry_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-registry/unknown"
class Test_sagemaker_models_registry_in_use:
def test_no_registries(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_model_registries = []
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import (
sagemaker_models_registry_in_use,
)
check = sagemaker_models_registry_in_use()
result = check.execute()
assert len(result) == 0
def test_registry_no_groups(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_model_registries = [
ModelRegistry(
name="SageMaker Model Registry",
arn=registry_arn,
region=AWS_REGION_EU_WEST_1,
has_groups=False,
has_approved_packages=False,
)
]
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import (
sagemaker_models_registry_in_use,
)
check = sagemaker_models_registry_in_use()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has no Model Package Groups."
)
assert result[0].resource_id == "SageMaker Model Registry"
assert result[0].resource_arn == registry_arn
assert result[0].region == AWS_REGION_EU_WEST_1
def test_registry_groups_no_approved_packages(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_model_registries = [
ModelRegistry(
name="SageMaker Model Registry",
arn=registry_arn,
region=AWS_REGION_EU_WEST_1,
has_groups=True,
has_approved_packages=False,
)
]
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import (
sagemaker_models_registry_in_use,
)
check = sagemaker_models_registry_in_use()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has Model Package Groups but no approved model packages."
)
assert result[0].resource_id == "SageMaker Model Registry"
assert result[0].resource_arn == registry_arn
assert result[0].region == AWS_REGION_EU_WEST_1
def test_registry_with_approved_packages(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_model_registries = [
ModelRegistry(
name="SageMaker Model Registry",
arn=registry_arn,
region=AWS_REGION_EU_WEST_1,
has_groups=True,
has_approved_packages=True,
)
]
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import (
sagemaker_models_registry_in_use,
)
check = sagemaker_models_registry_in_use()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has at least one approved model package."
)
assert result[0].resource_id == "SageMaker Model Registry"
assert result[0].resource_arn == registry_arn
assert result[0].region == AWS_REGION_EU_WEST_1
@@ -13,6 +13,11 @@ from tests.providers.aws.utils import (
set_mocked_aws_provider,
)
test_model_package_group_name = "test-model-package-group"
test_model_package_group_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-package-group/{test_model_package_group_name}"
test_model_package_name = "test-model-package"
test_model_package_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-package/{test_model_package_name}/1"
test_notebook_instance = "test-notebook-instance"
notebook_instance_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:notebook-instance/{test_notebook_instance}"
test_model = "test-model"
@@ -94,6 +99,25 @@ def mock_make_api_call(self, operation_name, kwarg):
"EnableNetworkIsolation": True,
"EnableInterContainerTrafficEncryption": True,
}
if operation_name == "ListModelPackageGroups":
return {
"ModelPackageGroupSummaryList": [
{
"ModelPackageGroupName": test_model_package_group_name,
"ModelPackageGroupArn": test_model_package_group_arn,
},
]
}
if operation_name == "ListModelPackages":
return {
"ModelPackageSummaryList": [
{
"ModelPackageName": test_model_package_name,
"ModelPackageArn": test_model_package_arn,
"ModelApprovalStatus": "Approved",
},
]
}
if operation_name == "ListTags":
return {
"Tags": [
@@ -379,3 +403,33 @@ class Test_SageMaker_Service:
if c[0][0] == sagemaker_service._list_tags_for_resource
]
assert len(tag_calls) == 5
# Test SageMaker list model package groups
def test_list_model_package_groups(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
sagemaker = SageMaker(aws_provider)
assert len(sagemaker.sagemaker_model_registries) == 1
registry = sagemaker.sagemaker_model_registries[0]
assert registry.region == AWS_REGION_EU_WEST_1
assert registry.has_groups is True
assert registry.has_approved_packages is True
def test_list_model_package_groups_access_denied(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
def mock_access_denied(self, operation_name, kwarg):
if operation_name == "ListModelPackageGroups":
raise botocore.exceptions.ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "User is not authorized to perform sagemaker:ListModelPackageGroups",
}
},
"ListModelPackageGroups",
)
return make_api_call(self, operation_name, kwarg)
with patch("botocore.client.BaseClient._make_api_call", new=mock_access_denied):
sagemaker = SageMaker(aws_provider)
assert sagemaker.sagemaker_model_registries == []
@@ -0,0 +1,328 @@
from unittest import mock
import botocore
from boto3 import client
from moto import mock_aws
from prowler.providers.aws.services.ses.ses_service import SES
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
set_mocked_aws_provider,
)
make_api_call = botocore.client.BaseClient._make_api_call
def mock_make_api_call_dkim_pass(self, operation_name, kwarg):
if operation_name == "ListEmailIdentities":
return {
"EmailIdentities": [
{
"IdentityType": "DOMAIN",
"IdentityName": "test-domain-dkim-pass",
}
],
}
elif operation_name == "GetEmailIdentity":
return {
"Policies": {},
"Tags": [],
"DkimAttributes": {
"Status": "SUCCESS",
"SigningEnabled": True,
"SigningAttributesOrigin": "AWS_SES",
},
}
return make_api_call(self, operation_name, kwarg)
def mock_make_api_call_dkim_fail_not_started(self, operation_name, kwarg):
if operation_name == "ListEmailIdentities":
return {
"EmailIdentities": [
{
"IdentityType": "DOMAIN",
"IdentityName": "test-domain-dkim-not-started",
}
],
}
elif operation_name == "GetEmailIdentity":
return {
"Policies": {},
"Tags": [],
"DkimAttributes": {
"Status": "NOT_STARTED",
"SigningEnabled": False,
"SigningAttributesOrigin": "AWS_SES",
},
}
return make_api_call(self, operation_name, kwarg)
def mock_make_api_call_dkim_fail_failed(self, operation_name, kwarg):
if operation_name == "ListEmailIdentities":
return {
"EmailIdentities": [
{
"IdentityType": "DOMAIN",
"IdentityName": "test-domain-dkim-failed",
}
],
}
elif operation_name == "GetEmailIdentity":
return {
"Policies": {},
"Tags": [],
"DkimAttributes": {
"Status": "FAILED",
"SigningEnabled": False,
"SigningAttributesOrigin": "AWS_SES",
},
}
return make_api_call(self, operation_name, kwarg)
def mock_make_api_call_dkim_pending(self, operation_name, kwarg):
if operation_name == "ListEmailIdentities":
return {
"EmailIdentities": [
{
"IdentityType": "DOMAIN",
"IdentityName": "test-domain-dkim-pending",
}
],
}
elif operation_name == "GetEmailIdentity":
return {
"Policies": {},
"Tags": [],
"DkimAttributes": {
"Status": "PENDING",
"SigningEnabled": False,
"SigningAttributesOrigin": "AWS_SES",
},
}
return make_api_call(self, operation_name, kwarg)
def mock_make_api_call_dkim_success_not_enabled(self, operation_name, kwarg):
if operation_name == "ListEmailIdentities":
return {
"EmailIdentities": [
{
"IdentityType": "DOMAIN",
"IdentityName": "test-domain-dkim-verified-not-signed",
}
],
}
elif operation_name == "GetEmailIdentity":
return {
"Policies": {},
"Tags": [],
"DkimAttributes": {
"Status": "SUCCESS",
"SigningEnabled": False,
"SigningAttributesOrigin": "AWS_SES",
},
}
return make_api_call(self, operation_name, kwarg)
class Test_ses_identity_dkim_enabled:
@mock_aws
def test_no_identities(self):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import (
ses_identity_dkim_enabled,
)
check = ses_identity_dkim_enabled()
result = check.execute()
assert len(result) == 0
@mock_aws
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_dkim_pass,
)
def test_identity_dkim_enabled_and_verified(self):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import (
ses_identity_dkim_enabled,
)
check = ses_identity_dkim_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SES identity test-domain-dkim-pass has DKIM signing enabled and verified."
)
assert result[0].resource_id == "test-domain-dkim-pass"
assert (
result[0].resource_arn
== f"arn:aws:ses:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:identity/test-domain-dkim-pass"
)
assert result[0].region == AWS_REGION_EU_WEST_1
@mock_aws
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_dkim_fail_not_started,
)
def test_identity_dkim_not_started(self):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import (
ses_identity_dkim_enabled,
)
check = ses_identity_dkim_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-domain-dkim-not-started has DKIM signing not verified (status: NOT_STARTED)."
)
assert result[0].resource_id == "test-domain-dkim-not-started"
assert result[0].region == AWS_REGION_EU_WEST_1
@mock_aws
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_dkim_fail_failed,
)
def test_identity_dkim_failed(self):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import (
ses_identity_dkim_enabled,
)
check = ses_identity_dkim_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-domain-dkim-failed has DKIM signing failed verification."
)
assert result[0].resource_id == "test-domain-dkim-failed"
assert result[0].region == AWS_REGION_EU_WEST_1
@mock_aws
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_dkim_pending,
)
def test_identity_dkim_pending(self):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import (
ses_identity_dkim_enabled,
)
check = ses_identity_dkim_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-domain-dkim-pending has DKIM signing not verified (status: PENDING)."
)
assert result[0].resource_id == "test-domain-dkim-pending"
assert result[0].region == AWS_REGION_EU_WEST_1
@mock_aws
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_dkim_success_not_enabled,
)
def test_identity_dkim_verified_but_not_enabled(self):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import (
ses_identity_dkim_enabled,
)
check = ses_identity_dkim_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-domain-dkim-verified-not-signed has DKIM verified but signing is disabled."
)
assert result[0].resource_id == "test-domain-dkim-verified-not-signed"
assert result[0].region == AWS_REGION_EU_WEST_1
@@ -29,6 +29,11 @@ def mock_make_api_call(self, operation_name, kwarg):
"policy1": '{"policy1": "value1"}',
},
"Tags": {"tag1": "value1", "tag2": "value2"},
"DkimAttributes": {
"Status": "SUCCESS",
"SigningEnabled": True,
"SigningAttributesOrigin": "AWS_SES",
},
}
return make_api_call(self, operation_name, kwarg)
@@ -78,3 +83,6 @@ class Test_SES_Service:
assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1
assert ses.email_identities[arn].policy == {"policy1": "value1"}
assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"}
assert ses.email_identities[arn].dkim_status == "SUCCESS"
assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES"
assert ses.email_identities[arn].dkim_signing_enabled is True
@@ -0,0 +1,271 @@
from unittest.mock import patch
from prowler.providers.googleworkspace.services.groups.groups_service import (
GroupsPolicies,
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
set_mocked_googleworkspace_provider,
)
class TestGroupsCreationRestricted:
def test_pass_all_restricted(self):
"""Test PASS when all creation settings are secure"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
groups_creation_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
create_groups_access_level="ADMIN_ONLY",
owners_can_allow_external_members=False,
owners_can_allow_incoming_mail_from_public=False,
)
check = groups_creation_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "PASS"
assert "properly restricted" in findings[0].status_extended
assert findings[0].resource_name == "Groups Policies"
assert findings[0].resource_id == "groupsPolicies"
assert findings[0].customer_id == CUSTOMER_ID
assert (
findings[0].resource
== GroupsPolicies(
create_groups_access_level="ADMIN_ONLY",
owners_can_allow_external_members=False,
owners_can_allow_incoming_mail_from_public=False,
).dict()
)
def test_fail_users_in_domain(self):
"""Test FAIL when anyone in org can create groups"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
groups_creation_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
create_groups_access_level="USERS_IN_DOMAIN",
owners_can_allow_external_members=False,
owners_can_allow_incoming_mail_from_public=False,
)
check = groups_creation_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "USERS_IN_DOMAIN" in findings[0].status_extended
def test_fail_external_members_allowed(self):
"""Test FAIL when external members are allowed"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
groups_creation_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
create_groups_access_level="ADMIN_ONLY",
owners_can_allow_external_members=True,
owners_can_allow_incoming_mail_from_public=False,
)
check = groups_creation_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "external members" in findings[0].status_extended
def test_fail_incoming_mail_allowed(self):
"""Test FAIL when incoming email from outside is allowed"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
groups_creation_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
create_groups_access_level="ADMIN_ONLY",
owners_can_allow_external_members=False,
owners_can_allow_incoming_mail_from_public=True,
)
check = groups_creation_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "incoming email" in findings[0].status_extended
def test_fail_all_defaults_none(self):
"""Test FAIL when all settings are None — only USERS_IN_DOMAIN default is insecure"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
groups_creation_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies()
check = groups_creation_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
# Only creation access level has an insecure default (USERS_IN_DOMAIN)
assert "ADMIN_ONLY" in findings[0].status_extended
assert "incoming email" not in findings[0].status_extended
assert "external members" not in findings[0].status_extended
def test_pass_admin_only_others_none(self):
"""Test PASS when creation is ADMIN_ONLY and boolean settings are None (secure defaults)"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
groups_creation_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
create_groups_access_level="ADMIN_ONLY",
)
check = groups_creation_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "PASS"
assert "properly restricted" in findings[0].status_extended
def test_fail_multiple_issues(self):
"""Test FAIL with all three sub-settings non-compliant"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
groups_creation_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
create_groups_access_level="ANYONE_CAN_CREATE",
owners_can_allow_external_members=True,
owners_can_allow_incoming_mail_from_public=True,
)
check = groups_creation_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "ANYONE_CAN_CREATE" in findings[0].status_extended
assert "external members" in findings[0].status_extended
assert "incoming email" in findings[0].status_extended
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
groups_creation_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = False
mock_client.policies = GroupsPolicies()
check = groups_creation_restricted()
findings = check.execute()
assert len(findings) == 0
@@ -0,0 +1,132 @@
from unittest.mock import patch
from prowler.providers.googleworkspace.services.groups.groups_service import (
GroupsPolicies,
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
set_mocked_googleworkspace_provider,
)
class TestGroupsExternalAccessRestricted:
def test_pass_domain_users_only(self):
"""Test PASS when external access is set to DOMAIN_USERS_ONLY"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import (
groups_external_access_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
collaboration_capability="DOMAIN_USERS_ONLY"
)
check = groups_external_access_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "PASS"
assert "private" in findings[0].status_extended
assert findings[0].resource_name == "Groups Policies"
assert findings[0].resource_id == "groupsPolicies"
assert findings[0].customer_id == CUSTOMER_ID
assert (
findings[0].resource
== GroupsPolicies(collaboration_capability="DOMAIN_USERS_ONLY").dict()
)
def test_fail_anyone_can_access(self):
"""Test FAIL when external access is set to ANYONE_CAN_ACCESS"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import (
groups_external_access_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
collaboration_capability="ANYONE_CAN_ACCESS"
)
check = groups_external_access_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "ANYONE_CAN_ACCESS" in findings[0].status_extended
def test_pass_no_policy_set(self):
"""Test PASS when no explicit policy is set (None) - Google default is DOMAIN_USERS_ONLY (secure)"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import (
groups_external_access_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(collaboration_capability=None)
check = groups_external_access_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import (
groups_external_access_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = False
mock_client.policies = GroupsPolicies()
check = groups_external_access_restricted()
findings = check.execute()
assert len(findings) == 0
@@ -0,0 +1,287 @@
from unittest.mock import MagicMock, patch
from tests.providers.googleworkspace.googleworkspace_fixtures import (
set_mocked_googleworkspace_provider,
)
class TestGroupsService:
def test_fetch_policies_all_settings(self):
"""Test fetching all Groups for Business policy settings from Cloud Identity API"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_credentials = MagicMock()
mock_session = MagicMock()
mock_session.credentials = mock_credentials
mock_provider.session = mock_session
mock_service = MagicMock()
mock_policies_list = MagicMock()
mock_policies_list.execute.return_value = {
"policies": [
{
"setting": {
"type": "settings/groups_for_business.groups_sharing",
"value": {
"collaborationCapability": "DOMAIN_USERS_ONLY",
"createGroupsAccessLevel": "ADMIN_ONLY",
"ownersCanAllowExternalMembers": False,
"ownersCanAllowIncomingMailFromPublic": False,
"viewTopicsDefaultAccessLevel": "GROUP_MEMBERS",
"ownersCanHideGroups": False,
"newGroupsAreHidden": False,
},
}
},
]
}
mock_service.policies().list.return_value = mock_policies_list
mock_service.policies().list_next.return_value = None
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
return_value=mock_service,
),
):
from prowler.providers.googleworkspace.services.groups.groups_service import (
Groups,
)
groups = Groups(mock_provider)
assert groups.policies_fetched is True
assert groups.policies.collaboration_capability == "DOMAIN_USERS_ONLY"
assert groups.policies.create_groups_access_level == "ADMIN_ONLY"
assert groups.policies.owners_can_allow_external_members is False
assert groups.policies.owners_can_allow_incoming_mail_from_public is False
assert groups.policies.view_topics_default_access_level == "GROUP_MEMBERS"
assert groups.policies.owners_can_hide_groups is False
assert groups.policies.new_groups_are_hidden is False
def test_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_session = MagicMock()
mock_session.credentials = MagicMock()
mock_provider.session = mock_session
mock_service = MagicMock()
mock_policies_list = MagicMock()
mock_policies_list.execute.return_value = {"policies": []}
mock_service.policies().list.return_value = mock_policies_list
mock_service.policies().list_next.return_value = None
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
return_value=mock_service,
),
):
from prowler.providers.googleworkspace.services.groups.groups_service import (
Groups,
)
groups = Groups(mock_provider)
assert groups.policies_fetched is True
assert groups.policies.collaboration_capability is None
assert groups.policies.create_groups_access_level is None
assert groups.policies.owners_can_allow_external_members is None
assert groups.policies.owners_can_allow_incoming_mail_from_public is None
assert groups.policies.view_topics_default_access_level is None
def test_fetch_policies_api_error(self):
"""Test handling of API errors during policy fetch"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_session = MagicMock()
mock_session.credentials = MagicMock()
mock_provider.session = mock_session
mock_service = MagicMock()
mock_service.policies().list.side_effect = Exception("API Error")
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
return_value=mock_service,
),
):
from prowler.providers.googleworkspace.services.groups.groups_service import (
Groups,
)
groups = Groups(mock_provider)
assert groups.policies_fetched is False
assert groups.policies.collaboration_capability is None
def test_fetch_policies_build_service_returns_none(self):
"""Test early return when _build_service fails to construct the client"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_session = MagicMock()
mock_session.credentials = MagicMock()
mock_provider.session = mock_session
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
return_value=None,
),
):
from prowler.providers.googleworkspace.services.groups.groups_service import (
Groups,
)
groups = Groups(mock_provider)
assert groups.policies_fetched is False
assert groups.policies.collaboration_capability is None
def test_fetch_policies_execute_raises(self):
"""Test inner except handler when request.execute() raises during pagination"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_session = MagicMock()
mock_session.credentials = MagicMock()
mock_provider.session = mock_session
mock_service = MagicMock()
mock_request = MagicMock()
mock_request.execute.side_effect = Exception("Execute failed")
mock_service.policies().list.return_value = mock_request
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
return_value=mock_service,
),
):
from prowler.providers.googleworkspace.services.groups.groups_service import (
Groups,
)
groups = Groups(mock_provider)
assert groups.policies_fetched is False
assert groups.policies.collaboration_capability is None
def test_fetch_policies_ignores_ou_and_group_level(self):
"""Test that OU-level and group-level policies are skipped, only customer-level used"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_session = MagicMock()
mock_session.credentials = MagicMock()
mock_provider.session = mock_session
mock_service = MagicMock()
mock_policies_list = MagicMock()
mock_policies_list.execute.return_value = {
"policies": [
{
# Customer-level: no policyQuery → should be used
"setting": {
"type": "settings/groups_for_business.groups_sharing",
"value": {
"collaborationCapability": "DOMAIN_USERS_ONLY",
"createGroupsAccessLevel": "ADMIN_ONLY",
},
}
},
{
# OU-level: has policyQuery.orgUnit → should be skipped
"policyQuery": {"orgUnit": "orgUnits/sales_team"},
"setting": {
"type": "settings/groups_for_business.groups_sharing",
"value": {
"collaborationCapability": "ANYONE_CAN_ACCESS",
},
},
},
{
# Group-level: has policyQuery.group → should be skipped
"policyQuery": {"group": "groups/contractors"},
"setting": {
"type": "settings/groups_for_business.groups_sharing",
"value": {
"collaborationCapability": "ANYONE_CAN_ACCESS",
},
},
},
]
}
mock_service.policies().list.return_value = mock_policies_list
mock_service.policies().list_next.return_value = None
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
return_value=mock_service,
),
):
from prowler.providers.googleworkspace.services.groups.groups_service import (
Groups,
)
groups = Groups(mock_provider)
assert groups.policies_fetched is True
assert groups.policies.collaboration_capability == "DOMAIN_USERS_ONLY"
assert groups.policies.create_groups_access_level == "ADMIN_ONLY"
def test_policies_model(self):
"""Test GroupsPolicies Pydantic model"""
from prowler.providers.googleworkspace.services.groups.groups_service import (
GroupsPolicies,
)
policies = GroupsPolicies(
collaboration_capability="DOMAIN_USERS_ONLY",
create_groups_access_level="ADMIN_ONLY",
owners_can_allow_external_members=False,
owners_can_allow_incoming_mail_from_public=False,
view_topics_default_access_level="GROUP_MEMBERS",
owners_can_hide_groups=False,
new_groups_are_hidden=False,
)
assert policies.collaboration_capability == "DOMAIN_USERS_ONLY"
assert policies.create_groups_access_level == "ADMIN_ONLY"
assert policies.owners_can_allow_external_members is False
assert policies.owners_can_allow_incoming_mail_from_public is False
assert policies.view_topics_default_access_level == "GROUP_MEMBERS"
assert policies.owners_can_hide_groups is False
assert policies.new_groups_are_hidden is False
@@ -0,0 +1,165 @@
from unittest.mock import patch
from prowler.providers.googleworkspace.services.groups.groups_service import (
GroupsPolicies,
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
set_mocked_googleworkspace_provider,
)
class TestGroupsViewConversationsRestricted:
def test_pass_group_members(self):
"""Test PASS when view conversations is set to GROUP_MEMBERS"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import (
groups_view_conversations_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
view_topics_default_access_level="GROUP_MEMBERS"
)
check = groups_view_conversations_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "PASS"
assert "group members" in findings[0].status_extended
assert findings[0].resource_name == "Groups Policies"
assert findings[0].resource_id == "groupsPolicies"
assert findings[0].customer_id == CUSTOMER_ID
assert (
findings[0].resource
== GroupsPolicies(
view_topics_default_access_level="GROUP_MEMBERS"
).dict()
)
def test_fail_domain_users(self):
"""Test FAIL when view conversations is set to DOMAIN_USERS"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import (
groups_view_conversations_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
view_topics_default_access_level="DOMAIN_USERS"
)
check = groups_view_conversations_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "DOMAIN_USERS" in findings[0].status_extended
def test_fail_anyone_can_view(self):
"""Test FAIL when view conversations is set to ANYONE_CAN_VIEW_TOPICS"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import (
groups_view_conversations_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(
view_topics_default_access_level="ANYONE_CAN_VIEW_TOPICS"
)
check = groups_view_conversations_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "ANYONE_CAN_VIEW_TOPICS" in findings[0].status_extended
def test_fail_no_policy_set(self):
"""Test FAIL when no explicit policy is set (None) - Google default is DOMAIN_USERS (insecure)"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import (
groups_view_conversations_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GroupsPolicies(view_topics_default_access_level=None)
check = groups_view_conversations_restricted()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "default" in findings[0].status_extended
assert "all organization users" in findings[0].status_extended
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client"
) as mock_client,
):
from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import (
groups_view_conversations_restricted,
)
mock_client.provider = mock_provider
mock_client.policies_fetched = False
mock_client.policies = GroupsPolicies()
check = groups_view_conversations_restricted()
findings = check.execute()
assert len(findings) == 0
@@ -7,6 +7,7 @@ from prowler.providers.okta.exceptions.exceptions import (
OktaInsufficientPermissionsError,
OktaInvalidCredentialsError,
OktaInvalidOrgDomainError,
OktaInvalidProviderIdError,
OktaPrivateKeyFileError,
OktaSetUpIdentityError,
OktaSetUpSessionError,
@@ -20,6 +21,7 @@ EXPECTED_CODES = {
OktaInvalidOrgDomainError: 14004,
OktaPrivateKeyFileError: 14005,
OktaInsufficientPermissionsError: 14006,
OktaInvalidProviderIdError: 14007,
}
@@ -7,6 +7,7 @@ from prowler.providers.okta.exceptions.exceptions import (
OktaInsufficientPermissionsError,
OktaInvalidCredentialsError,
OktaInvalidOrgDomainError,
OktaInvalidProviderIdError,
OktaPrivateKeyFileError,
OktaSetUpIdentityError,
)
@@ -396,6 +397,55 @@ class Test_OktaProvider_test_connection:
with pytest.raises(OktaEnvironmentVariableError):
OktaProvider.test_connection()
def test_provider_id_match_succeeds(self, _clear_okta_env, tmp_path):
validate_p, session_p, identity_p = _mock_setup_paths()
with validate_p, session_p, identity_p:
connection = OktaProvider.test_connection(
okta_org_domain=OKTA_ORG_DOMAIN,
okta_client_id=OKTA_CLIENT_ID,
okta_private_key_file="/tmp/key.pem",
provider_id=OKTA_ORG_DOMAIN,
)
assert connection.is_connected is True
assert connection.error is None
def test_provider_id_match_is_case_insensitive(self, _clear_okta_env, tmp_path):
validate_p, session_p, identity_p = _mock_setup_paths()
with validate_p, session_p, identity_p:
connection = OktaProvider.test_connection(
okta_org_domain=OKTA_ORG_DOMAIN,
okta_client_id=OKTA_CLIENT_ID,
okta_private_key_file="/tmp/key.pem",
provider_id=OKTA_ORG_DOMAIN.upper(),
)
assert connection.is_connected is True
def test_provider_id_mismatch_raises(self, _clear_okta_env, tmp_path):
validate_p, session_p, identity_p = _mock_setup_paths()
with validate_p, session_p, identity_p:
with pytest.raises(OktaInvalidProviderIdError):
OktaProvider.test_connection(
okta_org_domain=OKTA_ORG_DOMAIN,
okta_client_id=OKTA_CLIENT_ID,
okta_private_key_file="/tmp/key.pem",
provider_id="other.okta.com",
)
def test_provider_id_mismatch_returns_error_when_raise_disabled(
self, _clear_okta_env, tmp_path
):
validate_p, session_p, identity_p = _mock_setup_paths()
with validate_p, session_p, identity_p:
connection = OktaProvider.test_connection(
okta_org_domain=OKTA_ORG_DOMAIN,
okta_client_id=OKTA_CLIENT_ID,
okta_private_key_file="/tmp/key.pem",
provider_id="other.okta.com",
raise_on_exception=False,
)
assert connection.is_connected is False
assert isinstance(connection.error, OktaInvalidProviderIdError)
class Test_OktaProvider_print_credentials:
def test_invokes_print_boxes_with_org_and_client(self, _clear_okta_env, tmp_path):
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.28.0] (Prowler UNRELEASED)
### 🚀 Added
- `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213)
---
## [1.27.0] (Prowler v5.27.0)
### 🚀 Added
+1 -1
View File
@@ -29,7 +29,7 @@ vi.mock("@/lib", () => ({
wait: vi.fn(),
}));
vi.mock("@/lib/provider-credentials/build-crendentials", () => ({
vi.mock("@/lib/provider-credentials/build-credentials", () => ({
buildSecretConfig: vi.fn(() => ({
secretType: "access-secret-key",
secret: { key: "value" },
+1 -1
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, getFormValue, wait } from "@/lib";
import { buildSecretConfig } from "@/lib/provider-credentials/build-crendentials";
import { buildSecretConfig } from "@/lib/provider-credentials/build-credentials";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { appendSanitizedProviderInFilters } from "@/lib/provider-filters";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
@@ -31,6 +31,7 @@ vi.mock("@/components/icons/providers-badge", () => ({
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
@@ -16,6 +16,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
@@ -51,6 +52,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
openstack: <OpenStackProviderBadge width={18} height={18} />,
vercel: <VercelProviderBadge width={18} height={18} />,
okta: <OktaProviderBadge width={18} height={18} />,
};
/** Common props shared by both batch and instant modes. */
@@ -31,6 +31,7 @@ vi.mock("@/components/icons/providers-badge", () => ({
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
@@ -89,6 +89,11 @@ const VercelProviderBadge = lazy(() =>
default: m.VercelProviderBadge,
})),
);
const OktaProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OktaProviderBadge,
})),
);
type IconProps = { width: number; height: number };
@@ -160,6 +165,10 @@ const PROVIDER_DATA: Record<
label: "Vercel",
icon: VercelProviderBadge,
},
okta: {
label: "Okta",
icon: OktaProviderBadge,
},
};
/** Common props shared by both batch and instant modes. */
@@ -33,6 +33,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
@@ -175,6 +176,11 @@ const KNOWN_NODE_VISUALS = {
description: "Vercel Account",
Icon: VercelProviderBadge,
},
oktaaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Okta Account",
Icon: OktaProviderBadge,
},
s3bucket: {
category: NODE_CATEGORY.STORAGE,
description: "S3 Bucket",
@@ -11,6 +11,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
@@ -34,6 +35,7 @@ export const PROVIDER_ICONS = {
cloudflare: CloudflareProviderBadge,
openstack: OpenStackProviderBadge,
vercel: VercelProviderBadge,
okta: OktaProviderBadge,
} as const;
interface ProviderIconCellProps {
@@ -14,6 +14,7 @@ import { ImageProviderBadge } from "./image-provider-badge";
import { KS8ProviderBadge } from "./ks8-provider-badge";
import { M365ProviderBadge } from "./m365-provider-badge";
import { MongoDBAtlasProviderBadge } from "./mongodbatlas-provider-badge";
import { OktaProviderBadge } from "./okta-provider-badge";
import { OpenStackProviderBadge } from "./openstack-provider-badge";
import { OracleCloudProviderBadge } from "./oraclecloud-provider-badge";
import { VercelProviderBadge } from "./vercel-provider-badge";
@@ -31,6 +32,7 @@ export {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
@@ -53,4 +55,5 @@ export const PROVIDER_BADGE_BY_NAME: Record<string, FC<IconSvgProps>> = {
Cloudflare: CloudflareProviderBadge,
OpenStack: OpenStackProviderBadge,
Vercel: VercelProviderBadge,
Okta: OktaProviderBadge,
};
@@ -0,0 +1,52 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { IconSvgProps } from "@/types";
const WHEEL_PATH =
"M34.6,0.4l-1.3,16c-0.6-0.1-1.2-0.1-1.9-0.1c-0.8,0-1.6,0.1-2.3,0.2l-0.7-7.7c0-0.2,0.2-0.5,0.4-0.5h1.3l-0.6-7.8c0-0.2,0.2-0.5,0.4-0.5h4.3C34.5,0,34.7,0.2,34.6,0.4L34.6,0.4L34.6,0.4z M23.8,1.2c-0.1-0.2-0.3-0.4-0.5-0.3l-4,1.5C19,2.5,18.9,2.8,19,3l3.3,7.1l-1.2,0.5c-0.2,0.1-0.3,0.3-0.2,0.6l3.3,7c1.2-0.7,2.5-1.2,3.9-1.5L23.8,1.2L23.8,1.2z M14,5.7l9.3,13.1c-1.2,0.8-2.2,1.7-3.1,2.7L14.5,16c-0.2-0.2-0.2-0.5,0-0.6l1-0.8L10,9c-0.2-0.2-0.2-0.5,0-0.6l3.3-2.7C13.5,5.4,13.8,5.5,14,5.7L14,5.7z M6.2,13.2c-0.2-0.1-0.5-0.1-0.6,0.1l-2.1,3.7c-0.1,0.2,0,0.5,0.2,0.6l7.1,3.4l-0.7,1.1c-0.1,0.2,0,0.5,0.2,0.6l7.1,3.2c0.5-1.3,1.2-2.5,2-3.6L6.2,13.2z M0.9,23.3c0-0.2,0.3-0.4,0.5-0.3l15.5,4c-0.4,1.3-0.6,2.7-0.7,4.1l-7.8-0.6c-0.2,0-0.4-0.2-0.4-0.5l0.2-1.3L0.6,28c-0.2,0-0.4-0.2-0.4-0.5L0.9,23.3L0.9,23.3L0.9,23.3z M0.4,33.8C0.1,33.8,0,34,0,34.3l0.8,4.2c0,0.2,0.3,0.4,0.5,0.3l7.6-2l0.2,1.3c0,0.2,0.3,0.4,0.5,0.3l7.5-2.1c-0.4-1.3-0.7-2.7-0.8-4.1L0.4,33.8L0.4,33.8z M2.9,44.9c-0.1-0.2,0-0.5,0.2-0.6l14.5-6.9c0.5,1.3,1.3,2.5,2.2,3.6l-6.3,4.5c-0.2,0.1-0.5,0.1-0.6-0.1L12,44.3l-6.5,4.5c-0.2,0.1-0.5,0.1-0.6-0.1L2.9,44.9L2.9,44.9z M20.4,41.9L9.1,53.3c-0.2,0.2-0.2,0.5,0,0.6l3.3,2.7c0.2,0.2,0.5,0.1,0.6-0.1l4.6-6.4l1,0.9c0.2,0.2,0.5,0.1,0.6-0.1l4.4-6.4C22.4,43.8,21.3,42.9,20.4,41.9L20.4,41.9z M18.2,60.1c-0.2-0.1-0.3-0.3-0.2-0.6L24.6,45c1.2,0.6,2.6,1.1,3.9,1.4l-2,7.5c-0.1,0.2-0.3,0.4-0.5,0.3l-1.2-0.5l-2.1,7.6c-0.1,0.2-0.3,0.4-0.5,0.3L18.2,60.1L18.2,60.1L18.2,60.1z M29.6,46.6l-1.3,16c0,0.2,0.2,0.5,0.4,0.5H33c0.2,0,0.4-0.2,0.4-0.5l-0.6-7.8h1.3c0.2,0,0.4-0.2,0.4-0.5l-0.7-7.7c-0.8,0.1-1.5,0.2-2.3,0.2C30.9,46.7,30.2,46.7,29.6,46.6L29.6,46.6z M45.1,3.4c0.1-0.2,0-0.5-0.2-0.6l-4-1.5c-0.2-0.1-0.5,0.1-0.5,0.3l-2.1,7.6l-1.2-0.5c-0.2-0.1-0.5,0.1-0.5,0.3l-2,7.5c1.4,0.3,2.7,0.8,3.9,1.4L45.1,3.4L45.1,3.4z M53.9,9.7L42.6,21.1c-0.9-1-2-1.9-3.2-2.6l4.4-6.4c0.1-0.2,0.4-0.2,0.6-0.1l1,0.9l4.6-6.4c0.1-0.2,0.4-0.2,0.6-0.1l3.3,2.7C54,9.3,54,9.6,53.9,9.7L53.9,9.7z M59.9,18.7c0.2-0.1,0.3-0.4,0.2-0.6L58,14.4c-0.1-0.2-0.4-0.3-0.6-0.1l-6.5,4.5l-0.7-1.1c-0.1-0.2-0.4-0.3-0.6-0.1L43.3,22c0.9,1.1,1.6,2.3,2.2,3.6L59.9,18.7L59.9,18.7z M62.2,24.5l0.7,4.2c0,0.2-0.1,0.5-0.4,0.5l-15.9,1.5c-0.1-1.4-0.4-2.8-0.8-4.1l7.5-2.1c0.2-0.1,0.5,0.1,0.5,0.3l0.2,1.3l7.6-2C61.9,24.1,62.1,24.3,62.2,24.5L62.2,24.5L62.2,24.5z M61.5,40c0.2,0.1,0.5-0.1,0.5-0.3l0.7-4.2c0-0.2-0.1-0.5-0.4-0.5l-7.8-0.7l0.2-1.3c0-0.2-0.1-0.5-0.4-0.5l-7.8-0.6c0,1.4-0.3,2.8-0.7,4.1L61.5,40L61.5,40L61.5,40z M57.4,49.6c-0.1,0.2-0.4,0.3-0.6,0.1l-13.2-9.1c0.8-1.1,1.5-2.3,2-3.6l7.1,3.2c0.2,0.1,0.3,0.4,0.2,0.6L52.2,42l7.1,3.4c0.2,0.1,0.3,0.4,0.2,0.6L57.4,49.6C57.4,49.6,57.4,49.6,57.4,49.6z M39.7,44.2L49,57.3c0.1,0.2,0.4,0.2,0.6,0.1l3.3-2.7c0.2-0.2,0.2-0.4,0-0.6l-5.5-5.6l1-0.8c0.2-0.2,0.2-0.4,0-0.6l-5.5-5.5C42,42.6,40.9,43.5,39.7,44.2L39.7,44.2L39.7,44.2z M39.7,62c-0.2,0.1-0.5-0.1-0.5-0.3l-4.2-15.4c1.4-0.3,2.7-0.8,3.9-1.5l3.3,7c0.1,0.2,0,0.5-0.2,0.6l-1.2,0.5l3.3,7.1c0.1,0.2,0,0.5-0.2,0.6L39.7,62L39.7,62L39.7,62z";
export const OktaProviderBadge: React.FC<IconSvgProps> = ({
size,
width,
height,
...props
}) => {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const w = size || width;
const h = size || height;
if (!mounted) {
return (
<span
aria-hidden="true"
style={{ display: "inline-block", width: w, height: h }}
/>
);
}
const fill = resolvedTheme === "dark" ? "#FFFFFF" : "#191919";
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 63 63"
width={w}
height={h}
aria-hidden="true"
focusable="false"
role="presentation"
{...props}
>
<path fill={fill} d={WHEEL_PATH} />
</svg>
);
};
@@ -21,6 +21,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
@@ -103,6 +104,11 @@ const PROVIDERS = [
label: "Vercel",
badge: VercelProviderBadge,
},
{
value: "okta",
label: "Okta",
badge: OktaProviderBadge,
},
] as const;
interface RadioGroupProviderProps {
@@ -30,6 +30,7 @@ import {
M365ClientSecretCredentials,
MongoDBAtlasCredentials,
OCICredentials,
OktaCredentials,
OpenStackCredentials,
ProviderType,
VercelCredentials,
@@ -59,6 +60,7 @@ import { IacCredentialsForm } from "./via-credentials/iac-credentials-form";
import { ImageCredentialsForm } from "./via-credentials/image-credentials-form";
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
import { MongoDBAtlasCredentialsForm } from "./via-credentials/mongodbatlas-credentials-form";
import { OktaCredentialsForm } from "./via-credentials/okta-credentials-form";
import { OpenStackCredentialsForm } from "./via-credentials/openstack-credentials-form";
import { OracleCloudCredentialsForm } from "./via-credentials/oraclecloud-credentials-form";
import { VercelCredentialsForm } from "./via-credentials/vercel-credentials-form";
@@ -279,6 +281,11 @@ export const BaseCredentialsForm = ({
control={form.control as unknown as Control<VercelCredentials>}
/>
)}
{providerType === "okta" && (
<OktaCredentialsForm
control={form.control as unknown as Control<OktaCredentials>}
/>
)}
{!hideActions && (
<div className="flex w-full justify-end gap-4">
@@ -121,6 +121,11 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
label: "Team ID",
placeholder: "e.g. team_xxxxxxxxxxxxxxxxxxxxxxxx",
};
case "okta":
return {
label: "Org Domain",
placeholder: "e.g. your-org.okta.com",
};
default:
return {
label: "Provider UID",
@@ -0,0 +1,53 @@
import { Control } from "react-hook-form";
import {
WizardInputField,
WizardTextareaField,
} from "@/components/providers/workflow/forms/fields";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { OktaCredentials } from "@/types";
export const OktaCredentialsForm = ({
control,
}: {
control: Control<OktaCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
Connect via OAuth 2.0 Private Key JWT
</div>
<div className="text-default-500 text-sm">
Provide the Client ID and PEM-encoded private key of an Okta API
Services app whose matching public key (JWK) is registered on the
service app.
</div>
</div>
<WizardInputField
control={control}
name={ProviderCredentialFields.OKTA_CLIENT_ID}
type="text"
label="Client ID"
labelPlacement="inside"
placeholder="e.g. 0oa123456789abcdef"
variant="bordered"
isRequired
/>
<WizardTextareaField
control={control}
name={ProviderCredentialFields.OKTA_PRIVATE_KEY}
label="Private Key"
labelPlacement="inside"
placeholder="Paste your Okta app private key here"
variant="bordered"
isRequired
/>
<div className="text-default-400 text-xs">
The private key is sent over TLS and stored as a secret in the backend.
You can rotate or revoke the public key from the Okta admin console at
any time.
</div>
</>
);
};
@@ -11,6 +11,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
@@ -49,6 +50,8 @@ export const getProviderLogo = (provider: ProviderType) => {
return <OpenStackProviderBadge width={35} height={35} />;
case "vercel":
return <VercelProviderBadge width={35} height={35} />;
case "okta":
return <OktaProviderBadge width={35} height={35} />;
default:
return null;
}
@@ -86,6 +89,8 @@ export const getProviderName = (provider: ProviderType): string => {
return "OpenStack";
case "vercel":
return "Vercel";
case "okta":
return "Okta";
default:
return "Unknown Provider";
}
+6
View File
@@ -248,6 +248,12 @@ export const useCredentialsForm = ({
...baseDefaults,
[ProviderCredentialFields.VERCEL_API_TOKEN]: "",
};
case "okta":
return {
...baseDefaults,
[ProviderCredentialFields.OKTA_CLIENT_ID]: "",
[ProviderCredentialFields.OKTA_PRIVATE_KEY]: "",
};
default:
return baseDefaults;
}
+5
View File
@@ -100,6 +100,11 @@ export const getProviderHelpText = (provider: string) => {
text: "Need help connecting your Vercel team?",
link: "https://goto.prowler.com/provider-vercel",
};
case "okta":
return {
text: "Need help connecting your Okta organization?",
link: "https://goto.prowler.com/provider-okta",
};
default:
return {
text: "How to setup a provider?",
@@ -260,6 +260,20 @@ export const buildVercelSecret = (formData: FormData) => {
return filterEmptyValues(secret);
};
export const buildOktaSecret = (formData: FormData) => {
const secret = {
[ProviderCredentialFields.OKTA_CLIENT_ID]: getFormValue(
formData,
ProviderCredentialFields.OKTA_CLIENT_ID,
),
[ProviderCredentialFields.OKTA_PRIVATE_KEY]: getFormValue(
formData,
ProviderCredentialFields.OKTA_PRIVATE_KEY,
),
};
return filterEmptyValues(secret);
};
export const buildOpenStackSecret = (formData: FormData) => {
const secret = {
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: getFormValue(
@@ -513,6 +527,10 @@ export const buildSecretConfig = (
secretType: "static",
secret: buildVercelSecret(formData),
}),
okta: () => ({
secretType: "static",
secret: buildOktaSecret(formData),
}),
};
const builder = secretBuilders[providerType];
@@ -91,6 +91,10 @@ export const ProviderCredentialFields = {
// Vercel fields
VERCEL_API_TOKEN: "api_token",
// Okta fields
OKTA_CLIENT_ID: "okta_client_id",
OKTA_PRIVATE_KEY: "okta_private_key",
} as const;
// Type for credential field values
@@ -150,6 +154,8 @@ export const ErrorPointers = {
"/data/attributes/secret/credentials_content",
GOOGLEWORKSPACE_DELEGATED_USER: "/data/attributes/secret/delegated_user",
VERCEL_API_TOKEN: "/data/attributes/secret/api_token",
OKTA_CLIENT_ID: "/data/attributes/secret/okta_client_id",
OKTA_PRIVATE_KEY: "/data/attributes/secret/okta_private_key",
} as const;
export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers];
+8 -1
View File
@@ -389,6 +389,12 @@ export type VercelCredentials = {
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type OktaCredentials = {
[ProviderCredentialFields.OKTA_CLIENT_ID]: string;
[ProviderCredentialFields.OKTA_PRIVATE_KEY]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type CredentialsFormSchema =
| AWSCredentials
| AWSCredentialsRole
@@ -406,7 +412,8 @@ export type CredentialsFormSchema =
| CloudflareCredentials
| OpenStackCredentials
| GoogleWorkspaceCredentials
| VercelCredentials;
| VercelCredentials
| OktaCredentials;
export interface SearchParamsProps {
[key: string]: string | string[] | undefined;
+105 -1
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { addCredentialsRoleFormSchema } from "./formSchemas";
import {
addCredentialsFormSchema,
addCredentialsRoleFormSchema,
addProviderFormSchema,
} from "./formSchemas";
const BASE_AWS_ROLE_VALUES = {
[ProviderCredentialFields.PROVIDER_ID]: "provider-123",
@@ -46,3 +50,103 @@ describe("addCredentialsRoleFormSchema", () => {
);
});
});
describe("addProviderFormSchema - okta", () => {
const validUidFixtures = [
"acme.okta.com",
"acme.oktapreview.com",
"acme.okta-emea.com",
"agency.okta-gov.com",
"agency.okta.mil",
"agency.okta-miltest.com",
"agency.trex-govcloud.com",
"Acme.okta.com",
" ACME.OKTA.COM ",
"Agency.Okta-Gov.com",
];
it.each(validUidFixtures)("accepts okta-managed org domain %s", (uid) => {
const result = addProviderFormSchema.safeParse({
providerType: "okta",
providerUid: uid,
providerAlias: "okta-test",
});
expect(result.success).toBe(true);
});
it.each([
["Acme.okta.com", "acme.okta.com"],
[" ACME.OKTA.COM ", "acme.okta.com"],
["Agency.Okta-Gov.com", "agency.okta-gov.com"],
])("normalizes okta org domain %s to %s", (input, expected) => {
const result = addProviderFormSchema.safeParse({
providerType: "okta",
providerUid: input,
providerAlias: "okta-test",
});
expect(result.success).toBe(true);
expect(
result.success && "providerUid" in result.data
? result.data.providerUid
: undefined,
).toBe(expected);
});
const invalidUidFixtures = [
"https://acme.okta.com",
"acme.example.com",
"acme.okta.com/path",
"",
];
it.each(invalidUidFixtures)("rejects invalid okta org domain %s", (uid) => {
const result = addProviderFormSchema.safeParse({
providerType: "okta",
providerUid: uid,
providerAlias: "okta-test",
});
expect(result.success).toBe(false);
});
});
describe("addCredentialsFormSchema - okta", () => {
const BASE_OKTA_VALUES = {
[ProviderCredentialFields.PROVIDER_ID]: "provider-okta-1",
[ProviderCredentialFields.PROVIDER_TYPE]: "okta",
} as const;
it("accepts okta credentials when client id and private key are present", () => {
const schema = addCredentialsFormSchema("okta");
const result = schema.safeParse({
...BASE_OKTA_VALUES,
[ProviderCredentialFields.OKTA_CLIENT_ID]: "0oa123456789abcdef",
[ProviderCredentialFields.OKTA_PRIVATE_KEY]:
"-----BEGIN PRIVATE KEY-----\nMIIEvQ...\n-----END PRIVATE KEY-----",
});
expect(result.success).toBe(true);
});
it("reports missing okta private key on okta_private_key field", () => {
const schema = addCredentialsFormSchema("okta");
const result = schema.safeParse({
...BASE_OKTA_VALUES,
[ProviderCredentialFields.OKTA_CLIENT_ID]: "0oa123456789abcdef",
[ProviderCredentialFields.OKTA_PRIVATE_KEY]: "",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.issues).toContainEqual(
expect.objectContaining({
path: [ProviderCredentialFields.OKTA_PRIVATE_KEY],
}),
);
});
});
+29 -1
View File
@@ -163,6 +163,18 @@ export const addProviderFormSchema = z
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string().trim().min(1, "Team ID is required"),
}),
z.object({
providerType: z.literal("okta"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z
.string()
.trim()
.toLowerCase()
.regex(
/^[a-z0-9][a-z0-9-]*\.(okta\.com|oktapreview\.com|okta-emea\.com|okta-gov\.com|okta\.mil|okta-miltest\.com|trex-govcloud\.com)$/,
"Org Domain must be an Okta-managed domain (e.g. acme.okta.com), without scheme or path",
),
}),
]),
);
@@ -391,7 +403,23 @@ export const addCredentialsFormSchema = (
.trim()
.min(1, "API Token is required"),
}
: {}),
: providerType === "okta"
? {
[ProviderCredentialFields.OKTA_CLIENT_ID]:
z
.string()
.trim()
.min(1, "Client ID is required"),
[ProviderCredentialFields.OKTA_PRIVATE_KEY]:
z
.string()
.trim()
.min(
1,
"Private Key is required",
),
}
: {}),
})
.superRefine((data: Record<string, string | undefined>, ctx) => {
if (providerType === "m365") {
+2
View File
@@ -14,6 +14,7 @@ export const PROVIDER_TYPES = [
"cloudflare",
"openstack",
"vercel",
"okta",
] as const;
export type ProviderType = (typeof PROVIDER_TYPES)[number];
@@ -34,6 +35,7 @@ export const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
cloudflare: "Cloudflare",
openstack: "OpenStack",
vercel: "Vercel",
okta: "Okta",
};
export function getProviderDisplayName(providerId: string): string {
Generated
+1 -1
View File
@@ -3241,7 +3241,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.27.0"
version = "5.28.0"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },