Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery

This commit is contained in:
StylusFrost
2026-04-28 12:42:18 +02:00
committed by GitHub
143 changed files with 9600 additions and 709 deletions
+23
View File
@@ -0,0 +1,23 @@
# Prowler worktree automation for worktrunk (wt CLI).
# Runs automatically on `wt switch --create`.
# Block 1: setup + copy gitignored env files (.envrc, ui/.env.local)
# from the primary worktree — patterns selected via .worktreeinclude.
[[pre-start]]
skills = "./skills/setup.sh --claude"
python = "poetry env use python3.12"
envs = "wt step copy-ignored"
# Block 2: install Python deps (requires `poetry env use` from block 1).
[[pre-start]]
deps = "poetry install --with dev"
# Block 3: reminder — last visible output before `wt switch` returns.
# Hooks can't mutate the parent shell, so venv activation is manual.
[[pre-start]]
reminder = "echo '>> Reminder: activate the venv in this shell with: eval $(poetry env activate)'"
# Background: pnpm install runs while you start working.
# Tail logs via `wt config state logs`.
[post-start]
ui = "cd ui && pnpm install"
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.25.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+36 -263
View File
@@ -12,238 +12,33 @@ concurrency:
env:
PROWLER_VERSION: ${{ github.event.release.tag_name }}
BASE_BRANCH: master
DOCS_FILE: docs/getting-started/installation/prowler-app.mdx
permissions: {}
jobs:
detect-release-type:
bump-version:
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
permissions:
contents: read
outputs:
is_minor: ${{ steps.detect.outputs.is_minor }}
is_patch: ${{ steps.detect.outputs.is_patch }}
major_version: ${{ steps.detect.outputs.major_version }}
minor_version: ${{ steps.detect.outputs.minor_version }}
patch_version: ${{ steps.detect.outputs.patch_version }}
current_docs_version: ${{ steps.get_docs_version.outputs.current_docs_version }}
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Get current documentation version
id: get_docs_version
- name: Validate release version
run: |
CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' docs/getting-started/installation/prowler-app.mdx)
echo "current_docs_version=${CURRENT_DOCS_VERSION}" >> "${GITHUB_OUTPUT}"
echo "Current documentation version: $CURRENT_DOCS_VERSION"
- name: Detect release type and parse version
id: detect
run: |
if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR_VERSION=${BASH_REMATCH[1]}
MINOR_VERSION=${BASH_REMATCH[2]}
PATCH_VERSION=${BASH_REMATCH[3]}
echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}"
if (( MAJOR_VERSION != 5 )); then
echo "::error::Releasing another Prowler major version, aborting..."
exit 1
fi
if (( PATCH_VERSION == 0 )); then
echo "is_minor=true" >> "${GITHUB_OUTPUT}"
echo "is_patch=false" >> "${GITHUB_OUTPUT}"
echo "✓ Minor release detected: $PROWLER_VERSION"
else
echo "is_minor=false" >> "${GITHUB_OUTPUT}"
echo "is_patch=true" >> "${GITHUB_OUTPUT}"
echo "✓ Patch release detected: $PROWLER_VERSION"
fi
else
if [[ ! $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)"
exit 1
fi
bump-minor-version:
needs: detect-release-type
if: needs.detect-release-type.outputs.is_minor == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Calculate next minor version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
CURRENT_DOCS_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION}"
NEXT_MINOR_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
echo "NEXT_MINOR_VERSION=${NEXT_MINOR_VERSION}" >> "${GITHUB_ENV}"
echo "Current documentation version: $CURRENT_DOCS_VERSION"
echo "Current release version: $PROWLER_VERSION"
echo "Next minor version: $NEXT_MINOR_VERSION"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION: ${{ needs.detect-release-type.outputs.current_docs_version }}
- name: Bump versions in documentation for master
run: |
set -e
# Update prowler-app.mdx with current release version
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
echo "Files modified:"
git --no-pager diff
- name: Create PR for documentation update to master
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.BASE_BRANCH }}
commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
branch: docs-version-update-to-v${{ env.PROWLER_VERSION }}
title: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}.
### Files Updated
- `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION`
- All `*.mdx` files with `<VersionBadge>` components
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
- name: Checkout version branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
persist-credentials: false
- name: Calculate first patch version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
CURRENT_DOCS_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION}"
FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
echo "FIRST_PATCH_VERSION=${FIRST_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "First patch version: $FIRST_PATCH_VERSION"
echo "Version branch: $VERSION_BRANCH"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION: ${{ needs.detect-release-type.outputs.current_docs_version }}
- name: Bump versions in documentation for version branch
run: |
set -e
# Update prowler-app.mdx with current release version
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
echo "Files modified:"
git --no-pager diff
- name: Create PR for documentation update to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
branch: docs-version-update-to-v${{ env.PROWLER_VERSION }}-branch
title: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}.
### Files Updated
- `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION`
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
bump-patch-version:
needs: detect-release-type
if: needs.detect-release-type.outputs.is_patch == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Calculate next patch version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
PATCH_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION}
CURRENT_DOCS_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION}"
NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1))
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "Current documentation version: $CURRENT_DOCS_VERSION"
echo "Current release version: $PROWLER_VERSION"
echo "Next patch version: $NEXT_PATCH_VERSION"
echo "Target branch: $VERSION_BRANCH"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION: ${{ needs.detect-release-type.outputs.patch_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION: ${{ needs.detect-release-type.outputs.current_docs_version }}
if (( ${BASH_REMATCH[1]} != 5 )); then
echo "::error::Releasing another Prowler major version, aborting..."
exit 1
fi
- name: Checkout master branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -251,26 +46,43 @@ jobs:
ref: ${{ env.BASE_BRANCH }}
persist-credentials: false
- name: Bump versions in documentation for master
- name: Read current docs version on master
id: docs_version
run: |
CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' "${DOCS_FILE}")
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
echo "Current docs version on master: $CURRENT_DOCS_VERSION"
echo "Target release version: $PROWLER_VERSION"
# Skip if master is already at or ahead of the release version
# (re-run, or patch shipped against an older minor line)
HIGHEST=$(printf '%s\n%s\n' "${CURRENT_DOCS_VERSION}" "${PROWLER_VERSION}" | sort -V | tail -n1)
if [[ "${CURRENT_DOCS_VERSION}" == "${PROWLER_VERSION}" || "${HIGHEST}" != "${PROWLER_VERSION}" ]]; then
echo "skip=true" >> "${GITHUB_OUTPUT}"
echo "Skipping bump: current ($CURRENT_DOCS_VERSION) >= release ($PROWLER_VERSION)"
else
echo "skip=false" >> "${GITHUB_OUTPUT}"
fi
- name: Bump versions in documentation
if: steps.docs_version.outputs.skip == 'false'
run: |
set -e
# Update prowler-app.mdx with current release version
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}"
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}"
echo "Files modified:"
git --no-pager diff
- name: Create PR for documentation update to master
if: steps.docs_version.outputs.skip == 'false'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.BASE_BRANCH }}
commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
branch: docs-version-update-to-v${{ env.PROWLER_VERSION }}
title: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
commit-message: 'chore(docs): Bump version to v${{ env.PROWLER_VERSION }}'
branch: docs-version-bump-to-v${{ env.PROWLER_VERSION }}
title: 'chore(docs): Bump version to v${{ env.PROWLER_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
@@ -283,42 +95,3 @@ jobs:
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
- name: Checkout version branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ env.VERSION_BRANCH }}
persist-credentials: false
- name: Bump versions in documentation for version branch
run: |
set -e
# Update prowler-app.mdx with current release version
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
echo "Files modified:"
git --no-pager diff
- name: Create PR for documentation update to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
branch: docs-version-update-to-v${{ env.PROWLER_VERSION }}-branch
title: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}.
### Files Updated
- `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION`
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
+9 -9
View File
@@ -113,9 +113,9 @@ jobs:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: master
commit-message: 'chore(release): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
branch: version-bump-to-v${{ env.NEXT_MINOR_VERSION }}
title: 'chore(release): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
commit-message: 'chore(sdk): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
branch: sdk-version-bump-to-v${{ env.NEXT_MINOR_VERSION }}
title: 'chore(sdk): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
@@ -165,9 +165,9 @@ jobs:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(release): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
branch: version-bump-to-v${{ env.FIRST_PATCH_VERSION }}
title: 'chore(release): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
commit-message: 'chore(sdk): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
branch: sdk-version-bump-to-v${{ env.FIRST_PATCH_VERSION }}
title: 'chore(sdk): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
@@ -233,9 +233,9 @@ jobs:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(release): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
branch: version-bump-to-v${{ env.NEXT_PATCH_VERSION }}
title: 'chore(release): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
commit-message: 'chore(sdk): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
branch: sdk-version-bump-to-v${{ env.NEXT_PATCH_VERSION }}
title: 'chore(sdk): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
+2
View File
@@ -0,0 +1,2 @@
.envrc
ui/.env.local
+19
View File
@@ -9,6 +9,9 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
ARG TRIVY_VERSION=0.69.2
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
@@ -48,6 +51,22 @@ RUN ARCH=$(uname -m) && \
mkdir -p /tmp/.cache/trivy && \
chmod 777 /tmp/.cache/trivy
# Install zizmor for GitHub Actions workflow scanning
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
ZIZMOR_ARCH="x86_64-unknown-linux-gnu" ; \
elif [ "$ARCH" = "aarch64" ]; then \
ZIZMOR_ARCH="aarch64-unknown-linux-gnu" ; \
else \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
chmod +x /usr/local/bin/zizmor && \
rm -rf /tmp/zizmor.tar.gz /tmp/zizmor-extract
# Add prowler user
RUN addgroup --gid 1000 prowler && \
adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler
+14 -2
View File
@@ -2,15 +2,23 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.26.0] (Prowler UNRELEASED)
## [1.26.0] (Prowler v5.25.0)
### 🚀 Added
- CIS Benchmark PDF report generation for scans, exposing the latest CIS version per provider via `GET /scans/{id}/cis/{name}/` and picking the variant dynamically via `_pick_latest_cis_variant` (no hard-coded provider → version mapping) [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650)
- CIS Benchmark PDF report generation for scans, exposing the latest CIS version per provider via `GET /scans/{id}/cis/{name}/` [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650)
- `/overviews/resource-groups` (resource inventory), `/overviews/categories` and `/overviews/attack-surfaces` now reflect newly-muted findings without waiting for the next scan. The post-mute `reaggregate-all-finding-group-summaries` task now also dispatches `aggregate_scan_resource_group_summaries_task`, `aggregate_scan_category_summaries_task` and `aggregate_attack_surface_task` per latest scan of every `(provider, day)` pair, rebuilding `ScanGroupSummary`, `ScanCategorySummary` and `AttackSurfaceOverview` alongside the tables already covered in #10827 [(#10843)](https://github.com/prowler-cloud/prowler/pull/10843)
- Install zizmor v1.24.1 in API Docker image for GitHub Actions workflow scanning [(#10607)](https://github.com/prowler-cloud/prowler/pull/10607)
### 🔄 Changed
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
- `aggregate_findings`, `aggregate_attack_surface`, `aggregate_scan_resource_group_summaries` and `aggregate_scan_category_summaries` now upsert via `bulk_create(update_conflicts=True, ...)` instead of the prior `ignore_conflicts=True` / plain INSERT / `already backfilled` short-circuit. Re-runs triggered by the post-mute reaggregation pipeline no longer trip the `unique_*_per_scan` constraints nor silently drop updates, and are race-safe under concurrent writers (e.g. scan completion overlapping with a fresh mute rule) [(#10843)](https://github.com/prowler-cloud/prowler/pull/10843)
- Rename the scan-category and scan-resource-group summary aggregators from `backfill_*` to `aggregate_*` [(#10843)](https://github.com/prowler-cloud/prowler/pull/10843)
### 🐞 Fixed
- `generate_outputs_task` crashing with `KeyError` for compliance frameworks listed by `get_compliance_frameworks` but not loadable by `Compliance.get_bulk` [(#10903)](https://github.com/prowler-cloud/prowler/pull/10903)
---
@@ -24,6 +32,10 @@ All notable changes to the **Prowler API** are documented in this file.
- Attack Paths: Neo4j driver `connection_acquisition_timeout` is now configurable via `NEO4J_CONN_ACQUISITION_TIMEOUT` (default lowered from 120 s to 15 s) [(#10873)](https://github.com/prowler-cloud/prowler/pull/10873)
### 🐞 Fixed
- `/tmp/prowler_api_output` saturation in compliance report workers: the final `rmtree` in `generate_compliance_reports` now only waits on frameworks actually generated for the provider (so unsupported frameworks no longer leave a placeholder `results` entry that blocks cleanup), output directories are created lazily per enabled framework, and both `generate_compliance_reports` and `generate_outputs_task` run an opportunistic stale cleanup at task start with a 48h age threshold, a per-host `fcntl` throttle, a 50-deletions-per-run cap, and guards that protect EXECUTING scans and scans whose `output_location` still points to a local path (metadata lookups routed through the admin DB so RLS does not hide those rows) [(#10874)](https://github.com/prowler-cloud/prowler/pull/10874)
---
## [1.25.3] (Prowler v5.24.3)
+19
View File
@@ -8,6 +8,9 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
ARG TRIVY_VERSION=0.69.2
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
@@ -58,6 +61,22 @@ RUN ARCH=$(uname -m) && \
mkdir -p /tmp/.cache/trivy && \
chmod 777 /tmp/.cache/trivy
# Install zizmor for GitHub Actions workflow scanning
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
ZIZMOR_ARCH="x86_64-unknown-linux-gnu" ; \
elif [ "$ARCH" = "aarch64" ]; then \
ZIZMOR_ARCH="aarch64-unknown-linux-gnu" ; \
else \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
chmod +x /usr/local/bin/zizmor && \
rm -rf /tmp/zizmor.tar.gz /tmp/zizmor-extract
# Add prowler user
RUN addgroup --gid 1000 prowler && \
adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler
+1 -1
View File
@@ -50,7 +50,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.26.0"
version = "1.27.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
+7 -8
View File
@@ -1,7 +1,6 @@
from collections.abc import Iterable, Mapping
from api.models import Provider
from prowler.config.config import get_available_compliance_frameworks
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.models import CheckMetadata
@@ -95,12 +94,12 @@ PROWLER_CHECKS = LazyChecksMapping()
def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]:
"""
Retrieve and cache the list of available compliance frameworks for a specific cloud provider.
"""List compliance frameworks the API can load for `provider_type`.
This function lazily loads and caches the available compliance frameworks (e.g., CIS, MITRE, ISO)
for each provider type (AWS, Azure, GCP, etc.) on first access. Subsequent calls for the same
provider will return the cached result.
The list is sourced from `Compliance.get_bulk` so that the names
returned here are guaranteed to be loadable by the bulk loader. This
prevents downstream key mismatches (e.g. CSV report generation iterating
framework names and looking them up in the bulk dict).
Args:
provider_type (Provider.ProviderChoices): The cloud provider type for which to retrieve
@@ -112,8 +111,8 @@ def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[s
"""
global AVAILABLE_COMPLIANCE_FRAMEWORKS
if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS:
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = (
get_available_compliance_frameworks(provider_type)
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = list(
Compliance.get_bulk(provider_type).keys()
)
return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type]
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.26.0
version: 1.27.0
description: |-
Prowler API specification.
@@ -1,13 +1,18 @@
from unittest.mock import MagicMock, patch
import pytest
from api import compliance as compliance_module
from api.compliance import (
generate_compliance_overview_template,
generate_scan_compliance,
get_compliance_frameworks,
get_prowler_provider_checks,
get_prowler_provider_compliance,
load_prowler_checks,
)
from api.models import Provider
from prowler.lib.check.compliance_models import Compliance
class TestCompliance:
@@ -250,3 +255,58 @@ class TestCompliance:
}
assert template == expected_template
@pytest.fixture
def reset_compliance_cache():
"""Reset the module-level cache so each test starts cold."""
previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS)
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
try:
yield
finally:
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous)
class TestGetComplianceFrameworks:
def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache):
with patch("api.compliance.Compliance") as mock_compliance:
mock_compliance.get_bulk.return_value = {
"cis_1.4_aws": MagicMock(),
"mitre_attack_aws": MagicMock(),
}
result = get_compliance_frameworks(Provider.ProviderChoices.AWS)
assert sorted(result) == ["cis_1.4_aws", "mitre_attack_aws"]
mock_compliance.get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS)
def test_caches_result_per_provider(self, reset_compliance_cache):
with patch("api.compliance.Compliance") as mock_compliance:
mock_compliance.get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
get_compliance_frameworks(Provider.ProviderChoices.AWS)
get_compliance_frameworks(Provider.ProviderChoices.AWS)
# Cached after first call.
assert mock_compliance.get_bulk.call_count == 1
@pytest.mark.parametrize(
"provider_type",
[choice.value for choice in Provider.ProviderChoices],
)
def test_listing_is_subset_of_bulk(self, reset_compliance_cache, provider_type):
"""Regression for CLOUD-API-40S: every name returned by
``get_compliance_frameworks`` must be loadable via ``Compliance.get_bulk``.
A divergence here is what produced ``KeyError: 'csa_ccm_4.0'`` in
``generate_outputs_task`` after universal/multi-provider compliance
JSONs were introduced at the top-level ``prowler/compliance/`` path.
"""
bulk_keys = set(Compliance.get_bulk(provider_type).keys())
listed = set(get_compliance_frameworks(provider_type))
missing = listed - bulk_keys
assert not missing, (
f"get_compliance_frameworks({provider_type!r}) returned names not "
f"loadable by Compliance.get_bulk: {sorted(missing)}"
)
+1 -1
View File
@@ -422,7 +422,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.26.0"
spectacular_settings.VERSION = "1.27.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
+4 -4
View File
@@ -14,8 +14,8 @@ from rest_framework import status
from rest_framework.test import APIClient
from tasks.jobs.backfill import (
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
backfill_scan_resource_group_summaries,
aggregate_scan_category_summaries,
aggregate_scan_resource_group_summaries,
)
from api.attack_paths import (
@@ -1445,8 +1445,8 @@ def latest_scan_finding_with_categories(
)
finding.add_resources([resource])
backfill_resource_scan_summaries(tenant_id, str(scan.id))
backfill_scan_category_summaries(tenant_id, str(scan.id))
backfill_scan_resource_group_summaries(tenant_id, str(scan.id))
aggregate_scan_category_summaries(tenant_id, str(scan.id))
aggregate_scan_resource_group_summaries(tenant_id, str(scan.id))
return finding
+46 -26
View File
@@ -297,12 +297,15 @@ def backfill_daily_severity_summaries(tenant_id: str, days: int = None):
}
def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
def aggregate_scan_category_summaries(tenant_id: str, scan_id: str):
"""
Backfill ScanCategorySummary for a completed scan.
Aggregates category counts from all findings in the scan and creates
one ScanCategorySummary row per (category, severity) combination.
Idempotent: re-runs replace the scan's existing rows so counts stay in
sync with `Finding.muted` updates triggered outside scan completion
(e.g. mute rules).
Args:
tenant_id: Target tenant UUID
@@ -312,11 +315,6 @@ def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
dict: Status indicating whether backfill was performed
"""
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
if ScanCategorySummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id
).exists():
return {"status": "already backfilled"}
if not Scan.objects.filter(
tenant_id=tenant_id,
id=scan_id,
@@ -337,9 +335,6 @@ def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
cache=category_counts,
)
if not category_counts:
return {"status": "no categories to backfill"}
category_summaries = [
ScanCategorySummary(
tenant_id=tenant_id,
@@ -353,20 +348,38 @@ def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
for (category, severity), counts in category_counts.items()
]
with rls_transaction(tenant_id):
ScanCategorySummary.objects.bulk_create(
category_summaries, batch_size=500, ignore_conflicts=True
)
if category_summaries:
with rls_transaction(tenant_id):
# Upsert so re-runs (post-mute reaggregation) don't trip
# `unique_category_severity_per_scan`; race-safe under concurrent writers.
ScanCategorySummary.objects.bulk_create(
category_summaries,
batch_size=500,
update_conflicts=True,
unique_fields=["tenant_id", "scan_id", "category", "severity"],
update_fields=[
"total_findings",
"failed_findings",
"new_failed_findings",
],
)
if not category_counts:
return {"status": "no categories to backfill"}
return {"status": "backfilled", "categories_count": len(category_counts)}
def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
def aggregate_scan_resource_group_summaries(tenant_id: str, scan_id: str):
"""
Backfill ScanGroupSummary for a completed scan.
Aggregates resource group counts from all findings in the scan and creates
one ScanGroupSummary row per (resource_group, severity) combination.
Idempotent: re-runs replace the scan's existing rows so counts stay in
sync with `Finding.muted` updates triggered outside scan completion
(e.g. mute rules) and with resource-inventory views reading from this
table.
Args:
tenant_id: Target tenant UUID
@@ -376,11 +389,6 @@ def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
dict: Status indicating whether backfill was performed
"""
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
if ScanGroupSummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id
).exists():
return {"status": "already backfilled"}
if not Scan.objects.filter(
tenant_id=tenant_id,
id=scan_id,
@@ -418,9 +426,6 @@ def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
group_resources_cache=group_resources_cache,
)
if not resource_group_counts:
return {"status": "no resource groups to backfill"}
# Compute group-level resource counts (same value for all severity rows in a group)
group_resource_counts = {
grp: len(uids) for grp, uids in group_resources_cache.items()
@@ -439,10 +444,25 @@ def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
for (grp, severity), counts in resource_group_counts.items()
]
with rls_transaction(tenant_id):
ScanGroupSummary.objects.bulk_create(
resource_group_summaries, batch_size=500, ignore_conflicts=True
)
if resource_group_summaries:
with rls_transaction(tenant_id):
# Upsert so re-runs (post-mute reaggregation) don't trip
# `unique_resource_group_severity_per_scan`; race-safe under concurrent writers.
ScanGroupSummary.objects.bulk_create(
resource_group_summaries,
batch_size=500,
update_conflicts=True,
unique_fields=["tenant_id", "scan_id", "resource_group", "severity"],
update_fields=[
"total_findings",
"failed_findings",
"new_failed_findings",
"resources_count",
],
)
if not resource_group_counts:
return {"status": "no resource groups to backfill"}
return {"status": "backfilled", "resource_groups_count": len(resource_group_counts)}
+500 -119
View File
@@ -1,9 +1,13 @@
import gc
import os
import re
import time
from collections.abc import Iterable
from pathlib import Path
from shutil import rmtree
from uuid import UUID
import fcntl
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3
@@ -18,13 +22,38 @@ from tasks.jobs.reports import (
from tasks.jobs.threatscore import compute_threatscore_metrics
from tasks.jobs.threatscore_utils import _aggregate_requirement_statistics_from_database
from api.db_router import READ_REPLICA_ALIAS
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import rls_transaction
from api.models import Provider, ScanSummary, ThreatScoreSnapshot
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.finding import Finding as FindingOutput
logger = get_task_logger(__name__)
STALE_TMP_OUTPUT_MAX_AGE_HOURS = 48
STALE_TMP_OUTPUT_MAX_DELETIONS_PER_RUN = 50
STALE_TMP_OUTPUT_THROTTLE_SECONDS = 60 * 60
STALE_TMP_OUTPUT_LOCK_FILE_NAME = ".stale_tmp_cleanup.lock"
# Refuse to ever run rmtree against shared system roots; the configured
# DJANGO_TMP_OUTPUT_DIRECTORY must be a dedicated subdirectory.
_FORBIDDEN_CLEANUP_ROOTS = frozenset(
Path(p).resolve()
for p in ("/", "/tmp", "/var", "/var/tmp", "/home", "/root", "/etc", "/usr")
)
def _resolve_stale_tmp_safe_root() -> Path | None:
"""Resolve the configured tmp output directory, rejecting unsafe roots."""
try:
configured_root = Path(DJANGO_TMP_OUTPUT_DIRECTORY).resolve()
except OSError:
return None
if configured_root in _FORBIDDEN_CLEANUP_ROOTS:
return None
return configured_root
STALE_TMP_OUTPUT_SAFE_ROOT = _resolve_stale_tmp_safe_root()
# Matches CIS compliance_ids like "cis_1.4_aws", "cis_5.0_azure",
# "cis_1.10_kubernetes", "cis_3.0.1_aws". Requires at least one dotted
@@ -69,6 +98,324 @@ def _pick_latest_cis_variant(compliance_ids: Iterable[str]) -> str | None:
return best_name
def _should_run_stale_cleanup(
root_path: Path,
throttle_seconds: int = STALE_TMP_OUTPUT_THROTTLE_SECONDS,
) -> bool:
"""Throttle stale cleanup to at most once per hour per host."""
lock_file_path = root_path / STALE_TMP_OUTPUT_LOCK_FILE_NAME
now_timestamp = int(time.time())
try:
with lock_file_path.open("a+", encoding="ascii") as lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return False
lock_file.seek(0)
previous_value = lock_file.read().strip()
try:
last_run_timestamp = int(previous_value) if previous_value else 0
except ValueError:
last_run_timestamp = 0
if now_timestamp - last_run_timestamp < throttle_seconds:
return False
lock_file.seek(0)
lock_file.truncate()
lock_file.write(str(now_timestamp))
lock_file.flush()
os.fsync(lock_file.fileno())
except OSError as error:
logger.warning("Skipping stale tmp cleanup: lock file error (%s)", error)
return False
return True
def _is_scan_metadata_protected(
scan_path: Path,
scan_state: str | None,
output_location: str | None,
) -> bool:
"""
Return True when metadata indicates the directory must not be deleted.
Protected cases:
- Scan is still EXECUTING.
- Scan has a local output artifact path (non-S3) under this scan directory.
"""
if scan_state == StateChoices.EXECUTING.value:
return True
output_location = output_location or ""
if output_location and not output_location.startswith("s3://"):
try:
resolved_output_location = Path(output_location).resolve()
except OSError:
# Conservative fallback: if we cannot resolve a local output path,
# keep the directory to avoid deleting potentially needed artifacts.
return True
if (
resolved_output_location == scan_path
or scan_path in resolved_output_location.parents
):
return True
return False
def _is_scan_directory_protected(
tenant_id: str,
scan_id: str,
scan_path: Path,
) -> bool:
"""
DB-backed wrapper used when batch metadata is not already available.
"""
try:
scan_uuid = UUID(scan_id)
except ValueError:
return False
try:
scan = (
Scan.all_objects.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id, id=scan_uuid)
.only("state", "output_location")
.first()
)
except Exception as error:
logger.warning(
"Skipping stale tmp cleanup for %s/%s due to scan lookup error: %s",
tenant_id,
scan_id,
error,
)
return True
if not scan:
return False
return _is_scan_metadata_protected(
scan_path=scan_path,
scan_state=scan.state,
output_location=scan.output_location,
)
def _cleanup_stale_tmp_output_directories(
tmp_output_root: str,
max_age_hours: int = STALE_TMP_OUTPUT_MAX_AGE_HOURS,
exclude_scan: tuple[str, str] | None = None,
max_deletions_per_run: int = STALE_TMP_OUTPUT_MAX_DELETIONS_PER_RUN,
) -> int:
"""
Opportunistically delete stale scan directories under the tmp output root.
Expected directory layout:
<tmp_output_root>/<tenant_id>/<scan_id>/...
Each run that wins the per-host throttle sweeps every tenant directory so
leftover artifacts cannot pile up for tenants whose own tasks happen to
lose the throttle race.
Args:
tmp_output_root: Base tmp output path.
max_age_hours: Directory max age before deletion.
exclude_scan: Optional (tenant_id, scan_id) that must never be deleted.
max_deletions_per_run: Max number of scan directories deleted per run.
Returns:
Number of deleted scan directories.
"""
try:
if max_age_hours <= 0:
return 0
try:
root_path = Path(tmp_output_root).resolve()
except OSError as error:
logger.warning(
"Skipping stale tmp cleanup: unable to resolve %s (%s)",
tmp_output_root,
error,
)
return 0
if (
STALE_TMP_OUTPUT_SAFE_ROOT is None
or root_path != STALE_TMP_OUTPUT_SAFE_ROOT
):
logger.warning(
"Skipping stale tmp cleanup: unsupported root %s (allowed: %s)",
root_path,
STALE_TMP_OUTPUT_SAFE_ROOT,
)
return 0
if not root_path.exists() or not root_path.is_dir():
return 0
if max_deletions_per_run <= 0:
return 0
if not _should_run_stale_cleanup(root_path):
return 0
cutoff_timestamp = time.time() - (max_age_hours * 60 * 60)
deleted_scan_dirs = 0
try:
tenant_dirs = list(root_path.iterdir())
except OSError as error:
logger.warning(
"Skipping stale tmp cleanup: unable to list %s (%s)",
root_path,
error,
)
return 0
for tenant_dir in tenant_dirs:
if deleted_scan_dirs >= max_deletions_per_run:
break
if not tenant_dir.is_dir() or tenant_dir.is_symlink():
continue
try:
scan_dirs = list(tenant_dir.iterdir())
except OSError:
continue
stale_candidates: list[tuple[str, Path, UUID | None]] = []
for scan_dir in scan_dirs:
if not scan_dir.is_dir() or scan_dir.is_symlink():
continue
if exclude_scan and (
tenant_dir.name == exclude_scan[0]
and scan_dir.name == exclude_scan[1]
):
continue
try:
if scan_dir.stat().st_mtime >= cutoff_timestamp:
continue
except OSError:
continue
try:
resolved_scan_dir = scan_dir.resolve()
except OSError:
continue
if root_path not in resolved_scan_dir.parents:
logger.warning(
"Skipping stale tmp cleanup for path outside root: %s",
resolved_scan_dir,
)
continue
try:
scan_uuid: UUID | None = UUID(scan_dir.name)
except ValueError:
scan_uuid = None
stale_candidates.append((scan_dir.name, resolved_scan_dir, scan_uuid))
if not stale_candidates:
continue
scan_metadata_by_id: dict[UUID, tuple[str | None, str | None]] = {}
metadata_preload_succeeded = False
candidate_scan_ids = [
candidate[2] for candidate in stale_candidates if candidate[2]
]
if candidate_scan_ids:
try:
scan_rows = (
Scan.all_objects.using(MainRouter.admin_db)
.filter(
tenant_id=tenant_dir.name,
id__in=candidate_scan_ids,
)
.values_list("id", "state", "output_location")
)
scan_metadata_by_id = {
scan_id: (scan_state, output_location)
for scan_id, scan_state, output_location in scan_rows
}
metadata_preload_succeeded = True
except Exception as error:
logger.warning(
"Skipping stale tmp cleanup metadata preload for tenant %s: %s",
tenant_dir.name,
error,
)
else:
metadata_preload_succeeded = True
for scan_name, resolved_scan_dir, scan_uuid in stale_candidates:
if deleted_scan_dirs >= max_deletions_per_run:
break
should_check_scan_fallback = True
if scan_uuid and metadata_preload_succeeded:
should_check_scan_fallback = False
scan_metadata = scan_metadata_by_id.get(scan_uuid)
if scan_metadata:
scan_state, output_location = scan_metadata
if _is_scan_metadata_protected(
scan_path=resolved_scan_dir,
scan_state=scan_state,
output_location=output_location,
):
continue
if should_check_scan_fallback and _is_scan_directory_protected(
tenant_id=tenant_dir.name,
scan_id=scan_name,
scan_path=resolved_scan_dir,
):
continue
try:
rmtree(resolved_scan_dir, ignore_errors=True)
deleted_scan_dirs += 1
except Exception as error:
logger.warning(
"Error cleaning stale tmp directory %s: %s",
resolved_scan_dir,
error,
)
if deleted_scan_dirs:
logger.info(
"Deleted %s stale tmp output directories older than %sh from %s",
deleted_scan_dirs,
max_age_hours,
root_path,
)
if deleted_scan_dirs >= max_deletions_per_run:
logger.info(
"Stale tmp cleanup hit deletion limit (%s) for root %s",
max_deletions_per_run,
root_path,
)
return deleted_scan_dirs
except Exception as error:
logger.warning(
"Skipping stale tmp cleanup due to unexpected error: %s",
error,
exc_info=True,
)
return 0
def generate_threatscore_report(
tenant_id: str,
scan_id: str,
@@ -355,6 +702,19 @@ def generate_compliance_reports(
generate_cis,
)
try:
_cleanup_stale_tmp_output_directories(
DJANGO_TMP_OUTPUT_DIRECTORY,
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
exclude_scan=(tenant_id, scan_id),
)
except Exception as error:
logger.warning(
"Skipping stale tmp cleanup before compliance reports for scan %s: %s",
scan_id,
error,
)
results: dict = {}
# Validate that the scan has findings and get provider info
@@ -412,10 +772,31 @@ def generate_compliance_reports(
generate_csa = False
# For CIS we do NOT pre-check the provider against a hard-coded whitelist
# (that list drifts the moment a new CIS JSON ships). Instead, we let
# `_pick_latest_cis_variant` over `Compliance.get_bulk(provider_type)`
# return None for providers that lack CIS, and treat that as "nothing to
# do" below.
# (that list drifts the moment a new CIS JSON ships). Instead, we inspect
# the dynamically loaded framework map and pick the latest available CIS
# version, if any.
latest_cis: str | None = None
if generate_cis:
try:
frameworks_bulk = Compliance.get_bulk(provider_type)
latest_cis = _pick_latest_cis_variant(
name for name in frameworks_bulk.keys() if name.startswith("cis_")
)
except Exception as e:
logger.error("Error discovering CIS variants for %s: %s", provider_type, e)
results["cis"] = {"upload": False, "path": "", "error": str(e)}
generate_cis = False
else:
if latest_cis is None:
logger.info("No CIS variants available for provider %s", provider_type)
results["cis"] = {"upload": False, "path": ""}
generate_cis = False
else:
logger.info(
"Selected latest CIS variant for provider %s: %s",
provider_type,
latest_cis,
)
if (
not generate_threatscore
@@ -438,45 +819,56 @@ def generate_compliance_reports(
findings_cache = {}
logger.info("Created shared findings cache for all reports")
# Generate output directories
generated_report_keys: list[str] = []
output_paths: dict[str, str] = {}
out_dir: str | None = None
# Generate output directories only for enabled and supported report types.
try:
logger.info("Generating output directories")
threatscore_path = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="threatscore",
)
ens_path = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="ens",
)
nis2_path = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="nis2",
)
csa_path = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="csa",
)
cis_path = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="cis",
)
out_dir = str(Path(threatscore_path).parent.parent)
if generate_threatscore:
output_paths["threatscore"] = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="threatscore",
)
if generate_ens:
output_paths["ens"] = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="ens",
)
if generate_nis2:
output_paths["nis2"] = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="nis2",
)
if generate_csa:
output_paths["csa"] = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="csa",
)
if generate_cis and latest_cis:
output_paths["cis"] = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="cis",
)
if output_paths:
first_output_path = next(iter(output_paths.values()))
out_dir = str(Path(first_output_path).parent.parent)
except Exception as e:
logger.error("Error generating output directory: %s", e)
error_dict = {"error": str(e), "upload": False, "path": ""}
@@ -494,6 +886,8 @@ def generate_compliance_reports(
# Generate ThreatScore report
if generate_threatscore:
generated_report_keys.append("threatscore")
threatscore_path = output_paths["threatscore"]
compliance_id_threatscore = f"prowler_threatscore_{provider_type}"
pdf_path_threatscore = f"{threatscore_path}_threatscore_report.pdf"
logger.info(
@@ -595,6 +989,8 @@ def generate_compliance_reports(
# Generate ENS report
if generate_ens:
generated_report_keys.append("ens")
ens_path = output_paths["ens"]
compliance_id_ens = f"ens_rd2022_{provider_type}"
pdf_path_ens = f"{ens_path}_ens_report.pdf"
logger.info("Generating ENS report with compliance %s", compliance_id_ens)
@@ -629,6 +1025,8 @@ def generate_compliance_reports(
# Generate NIS2 report
if generate_nis2:
generated_report_keys.append("nis2")
nis2_path = output_paths["nis2"]
compliance_id_nis2 = f"nis2_{provider_type}"
pdf_path_nis2 = f"{nis2_path}_nis2_report.pdf"
logger.info("Generating NIS2 report with compliance %s", compliance_id_nis2)
@@ -664,6 +1062,8 @@ def generate_compliance_reports(
# Generate CSA CCM report
if generate_csa:
generated_report_keys.append("csa")
csa_path = output_paths["csa"]
compliance_id_csa = f"csa_ccm_4.0_{provider_type}"
pdf_path_csa = f"{csa_path}_csa_report.pdf"
logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa)
@@ -700,91 +1100,72 @@ def generate_compliance_reports(
# Generate CIS Benchmark report for the latest available version only.
# CIS ships multiple versions per provider (e.g. cis_1.4_aws, cis_5.0_aws,
# cis_6.0_aws); we dynamically pick the highest semantic version at run
# time rather than hard-coding a per-provider mapping. `Compliance.get_bulk`
# is the single source of truth for which providers have CIS.
if generate_cis:
latest_cis: str | None = None
# time rather than hard-coding a per-provider mapping.
if generate_cis and latest_cis:
generated_report_keys.append("cis")
cis_path = output_paths["cis"]
if out_dir is None:
out_dir = str(Path(cis_path).parent.parent)
pdf_path_cis = f"{cis_path}_cis_report.pdf"
try:
frameworks_bulk = Compliance.get_bulk(provider_type)
latest_cis = _pick_latest_cis_variant(
name for name in frameworks_bulk.keys() if name.startswith("cis_")
generate_cis_report(
tenant_id=tenant_id,
scan_id=scan_id,
compliance_id=latest_cis,
output_path=pdf_path_cis,
provider_id=provider_id,
only_failed=only_failed_cis,
include_manual=include_manual_cis,
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
)
except Exception as e:
logger.error("Error discovering CIS variants for %s: %s", provider_type, e)
results["cis"] = {"upload": False, "path": "", "error": str(e)}
if "cis" not in results:
if latest_cis is None:
logger.info("No CIS variants available for provider %s", provider_type)
results["cis"] = {"upload": False, "path": ""}
else:
upload_uri_cis = _upload_to_s3(
tenant_id,
scan_id,
pdf_path_cis,
f"cis/{Path(pdf_path_cis).name}",
)
if upload_uri_cis:
results["cis"] = {
"upload": True,
"path": upload_uri_cis,
}
logger.info(
"Selected latest CIS variant for provider %s: %s",
provider_type,
"CIS report %s uploaded to %s",
latest_cis,
upload_uri_cis,
)
else:
results["cis"] = {"upload": False, "path": out_dir}
logger.warning(
"CIS report %s saved locally at %s",
latest_cis,
out_dir,
)
pdf_path_cis = f"{cis_path}_cis_report.pdf"
try:
generate_cis_report(
tenant_id=tenant_id,
scan_id=scan_id,
compliance_id=latest_cis,
output_path=pdf_path_cis,
provider_id=provider_id,
only_failed=only_failed_cis,
include_manual=include_manual_cis,
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
)
upload_uri_cis = _upload_to_s3(
tenant_id,
scan_id,
pdf_path_cis,
f"cis/{Path(pdf_path_cis).name}",
)
except Exception as e:
logger.error("Error generating CIS report %s: %s", latest_cis, e)
results["cis"] = {
"upload": False,
"path": "",
"error": str(e),
}
finally:
# Free ReportLab/matplotlib memory before moving on.
gc.collect()
if upload_uri_cis:
results["cis"] = {
"upload": True,
"path": upload_uri_cis,
}
logger.info(
"CIS report %s uploaded to %s",
latest_cis,
upload_uri_cis,
)
else:
results["cis"] = {"upload": False, "path": out_dir}
logger.warning(
"CIS report %s saved locally at %s",
latest_cis,
out_dir,
)
# Clean up temporary files only if all generated reports were
# uploaded successfully. Reports skipped for provider incompatibility
# or missing CIS variants must not block cleanup.
all_uploaded = bool(generated_report_keys) and all(
results.get(report_key, {}).get("upload", False)
for report_key in generated_report_keys
)
except Exception as e:
logger.error("Error generating CIS report %s: %s", latest_cis, e)
results["cis"] = {
"upload": False,
"path": "",
"error": str(e),
}
finally:
# Free ReportLab/matplotlib memory before moving on.
gc.collect()
# Clean up temporary files only if every requested report has been
# successfully uploaded. All result entries now share the same flat
# shape, so the check is a single comprehension.
upload_flags = [
bool(entry.get("upload", False))
for entry in results.values()
if isinstance(entry, dict) and entry.get("upload") is not None
]
all_uploaded = bool(upload_flags) and all(upload_flags)
if all_uploaded:
if all_uploaded and out_dir:
try:
rmtree(Path(out_dir), ignore_errors=True)
logger.info("Cleaned up temporary files at %s", out_dir)
+47 -8
View File
@@ -1197,11 +1197,39 @@ def aggregate_findings(tenant_id: str, scan_id: str):
muted_changed=agg["muted_changed"],
)
for agg in aggregation
if agg["resources__service"] is not None
and agg["resources__region"] is not None
}
# Delete first so re-runs (e.g. post-mute reaggregation) don't hit
# the `unique_scan_summary` constraint.
ScanSummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id).delete()
ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000)
# Upsert so re-runs (post-mute reaggregation) don't trip
# `unique_scan_summary`; race-safe under concurrent writers.
ScanSummary.objects.bulk_create(
scan_aggregations,
batch_size=3000,
update_conflicts=True,
unique_fields=[
"tenant",
"scan",
"check_id",
"service",
"severity",
"region",
],
update_fields=[
"_pass",
"fail",
"muted",
"total",
"new",
"changed",
"unchanged",
"fail_new",
"fail_changed",
"pass_new",
"pass_changed",
"muted_new",
"muted_changed",
],
)
def _aggregate_findings_by_region(
@@ -1546,13 +1574,24 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
)
)
# Bulk create overview records
if overview_objects:
with rls_transaction(tenant_id):
AttackSurfaceOverview.objects.bulk_create(overview_objects, batch_size=500)
logger.info(
f"Created {len(overview_objects)} attack surface overview records for scan {scan_id}"
# Upsert so re-runs (post-mute reaggregation) don't trip
# `unique_attack_surface_per_scan`; race-safe under concurrent writers.
AttackSurfaceOverview.objects.bulk_create(
overview_objects,
batch_size=500,
update_conflicts=True,
unique_fields=["tenant_id", "scan_id", "attack_surface_type"],
update_fields=[
"total_findings",
"failed_findings",
"muted_failed_findings",
],
)
logger.info(
f"Upserted {len(overview_objects)} attack surface overview records for scan {scan_id}"
)
else:
logger.info(f"No attack surface overview records created for scan {scan_id}")
+42 -12
View File
@@ -20,8 +20,8 @@ from tasks.jobs.backfill import (
backfill_finding_group_summaries,
backfill_provider_compliance_scores,
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
backfill_scan_resource_group_summaries,
aggregate_scan_category_summaries,
aggregate_scan_resource_group_summaries,
)
from tasks.jobs.connection import (
check_integration_connection,
@@ -46,7 +46,11 @@ from tasks.jobs.lighthouse_providers import (
refresh_lighthouse_provider_models,
)
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.report import generate_compliance_reports_job
from tasks.jobs.report import (
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
_cleanup_stale_tmp_output_directories,
generate_compliance_reports_job,
)
from tasks.jobs.scan import (
aggregate_attack_surface,
aggregate_daily_severity,
@@ -440,6 +444,19 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
scan_id (str): The scan identifier.
provider_id (str): The provider_id id to be used in generating outputs.
"""
try:
_cleanup_stale_tmp_output_directories(
DJANGO_TMP_OUTPUT_DIRECTORY,
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
exclude_scan=(tenant_id, scan_id),
)
except Exception as error:
logger.warning(
"Skipping stale tmp cleanup before output generation for scan %s: %s",
scan_id,
error,
)
# Check if the scan has findings
if not ScanSummary.objects.filter(scan_id=scan_id).exists():
logger.info(f"No findings found for scan {scan_id}")
@@ -659,9 +676,9 @@ def backfill_finding_group_summaries_task(tenant_id: str, days: int = None):
return backfill_finding_group_summaries(tenant_id=tenant_id, days=days)
@shared_task(name="backfill-scan-category-summaries", queue="backfill")
@shared_task(name="scan-category-summaries", queue="overview")
@handle_provider_deletion
def backfill_scan_category_summaries_task(tenant_id: str, scan_id: str):
def aggregate_scan_category_summaries_task(tenant_id: str, scan_id: str):
"""
Backfill ScanCategorySummary for a completed scan.
@@ -671,12 +688,12 @@ def backfill_scan_category_summaries_task(tenant_id: str, scan_id: str):
tenant_id (str): The tenant identifier.
scan_id (str): The scan identifier.
"""
return backfill_scan_category_summaries(tenant_id=tenant_id, scan_id=scan_id)
return aggregate_scan_category_summaries(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(name="backfill-scan-resource-group-summaries", queue="backfill")
@shared_task(name="scan-resource-group-summaries", queue="overview")
@handle_provider_deletion
def backfill_scan_resource_group_summaries_task(tenant_id: str, scan_id: str):
def aggregate_scan_resource_group_summaries_task(tenant_id: str, scan_id: str):
"""
Backfill ScanGroupSummary for a completed scan.
@@ -686,7 +703,7 @@ def backfill_scan_resource_group_summaries_task(tenant_id: str, scan_id: str):
tenant_id (str): The tenant identifier.
scan_id (str): The scan identifier.
"""
return backfill_scan_resource_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
return aggregate_scan_resource_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(name="backfill-provider-compliance-scores", queue="backfill")
@@ -778,12 +795,16 @@ def reaggregate_all_finding_group_summaries_task(tenant_id: str):
limit. To keep the pre-aggregated tables consistent with that update,
this task re-runs the same per-scan aggregation pipeline that scan
completion runs on the latest completed scan of every (provider, day)
pair, rebuilding the three tables that power the read endpoints:
pair, rebuilding the tables that power the read endpoints:
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
`/overviews/findings-severity`, `/overviews/services`.
- `FindingGroupDailySummary` -> `/finding-groups` and
`/finding-groups/latest`.
- `ScanGroupSummary` -> `/overviews/resource-groups` (resource
inventory).
- `ScanCategorySummary` -> `/overviews/categories`.
- `AttackSurfaceOverview` -> `/overviews/attack-surfaces`.
Per-scan pipelines are dispatched in parallel via a Celery group so
wallclock scales with the worker pool.
@@ -815,8 +836,8 @@ def reaggregate_all_finding_group_summaries_task(tenant_id: str):
len(scan_ids),
)
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
# recomputed first; FindingGroupDailySummary reads from Finding
# directly and can run in parallel with the severity step.
# recomputed first; the other aggregators read Finding directly and
# can run in parallel with the severity step.
group(
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
@@ -827,6 +848,15 @@ def reaggregate_all_finding_group_summaries_task(tenant_id: str):
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_resource_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_category_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_attack_surface_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
),
)
for scan_id in scan_ids
+145 -14
View File
@@ -7,8 +7,8 @@ from tasks.jobs.backfill import (
backfill_compliance_summaries,
backfill_provider_compliance_scores,
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
backfill_scan_resource_group_summaries,
aggregate_scan_category_summaries,
aggregate_scan_resource_group_summaries,
)
from api.models import (
@@ -183,6 +183,10 @@ class TestBackfillComplianceSummaries:
def test_backfill_creates_compliance_summaries(
self, tenants_fixture, scans_fixture, compliance_requirements_overviews_fixture
):
# Fixture seeds compliance rows the backfill aggregates over; pytest
# injects it by parameter name, so we reference it explicitly here
# to keep static analysers from flagging it as unused.
del compliance_requirements_overviews_fixture
tenant = tenants_fixture[0]
scan = scans_fixture[0]
@@ -227,22 +231,86 @@ class TestBackfillComplianceSummaries:
@pytest.mark.django_db
class TestBackfillScanCategorySummaries:
def test_already_backfilled(self, scan_category_summary_fixture):
def test_rerun_with_no_findings_is_noop(self, scan_category_summary_fixture):
"""When the scan has no findings, the backfill is a no-op: it
reports `no categories to backfill` and leaves the table
untouched. The upsert path cannot drop rows it does not produce,
so any pre-existing row survives (matching the scan-completion
writer that used `ignore_conflicts=True`)."""
tenant_id = scan_category_summary_fixture.tenant_id
scan_id = scan_category_summary_fixture.scan_id
result = backfill_scan_category_summaries(str(tenant_id), str(scan_id))
result = aggregate_scan_category_summaries(str(tenant_id), str(scan_id))
assert result == {"status": "already backfilled"}
assert result == {"status": "no categories to backfill"}
assert ScanCategorySummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id, category="existing-category"
).exists()
def test_rerun_upserts_without_duplicating(self, findings_with_categories_fixture):
"""Calling the backfill twice upserts rather than raising on
`unique_category_severity_per_scan`; rows are updated in place
(same primary keys)."""
finding = findings_with_categories_fixture
tenant_id = str(finding.tenant_id)
scan_id = str(finding.scan_id)
aggregate_scan_category_summaries(tenant_id, scan_id)
first_ids = set(
ScanCategorySummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id
).values_list("id", flat=True)
)
aggregate_scan_category_summaries(tenant_id, scan_id)
second_ids = set(
ScanCategorySummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id
).values_list("id", flat=True)
)
assert first_ids == second_ids
assert len(first_ids) == 2 # 2 categories x 1 severity
def test_rerun_reflects_mute_between_runs(self, findings_with_categories_fixture):
"""Muting a finding between two backfill runs must move counters:
`failed_findings` and `new_failed_findings` drop to zero (muted
findings are excluded from those totals). Guards against a
regression where the upsert keeps stale counts from the first run."""
finding = findings_with_categories_fixture
tenant_id = str(finding.tenant_id)
scan_id = str(finding.scan_id)
aggregate_scan_category_summaries(tenant_id, scan_id)
before = list(
ScanCategorySummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
)
assert all(s.failed_findings == 1 for s in before)
assert all(s.new_failed_findings == 1 for s in before)
assert all(s.total_findings == 1 for s in before)
Finding.all_objects.filter(pk=finding.pk).update(muted=True)
aggregate_scan_category_summaries(tenant_id, scan_id)
after = list(
ScanCategorySummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
)
assert {s.id for s in after} == {s.id for s in before}
assert all(s.failed_findings == 0 for s in after)
assert all(s.new_failed_findings == 0 for s in after)
assert all(s.total_findings == 0 for s in after)
def test_not_completed_scan(self, get_not_completed_scans):
for scan in get_not_completed_scans:
result = backfill_scan_category_summaries(str(scan.tenant_id), str(scan.id))
result = aggregate_scan_category_summaries(
str(scan.tenant_id), str(scan.id)
)
assert result == {"status": "scan is not completed"}
def test_no_categories_to_backfill(self, scans_fixture):
scan = scans_fixture[1] # Failed scan with no findings
result = backfill_scan_category_summaries(str(scan.tenant_id), str(scan.id))
result = aggregate_scan_category_summaries(str(scan.tenant_id), str(scan.id))
assert result == {"status": "no categories to backfill"}
def test_successful_backfill(self, findings_with_categories_fixture):
@@ -250,7 +318,7 @@ class TestBackfillScanCategorySummaries:
tenant_id = str(finding.tenant_id)
scan_id = str(finding.scan_id)
result = backfill_scan_category_summaries(tenant_id, scan_id)
result = aggregate_scan_category_summaries(tenant_id, scan_id)
# 2 categories × 1 severity = 2 rows
assert result == {"status": "backfilled", "categories_count": 2}
@@ -311,24 +379,87 @@ def scan_resource_group_summary_fixture(scans_fixture):
@pytest.mark.django_db
class TestBackfillScanGroupSummaries:
def test_already_backfilled(self, scan_resource_group_summary_fixture):
def test_rerun_with_no_findings_is_noop(self, scan_resource_group_summary_fixture):
"""When the scan has no findings, the backfill is a no-op: it
reports `no resource groups to backfill` and leaves the table
untouched. The upsert path cannot drop rows it does not produce,
so any pre-existing row survives (matching the scan-completion
writer that used `ignore_conflicts=True`)."""
tenant_id = scan_resource_group_summary_fixture.tenant_id
scan_id = scan_resource_group_summary_fixture.scan_id
result = backfill_scan_resource_group_summaries(str(tenant_id), str(scan_id))
result = aggregate_scan_resource_group_summaries(str(tenant_id), str(scan_id))
assert result == {"status": "already backfilled"}
assert result == {"status": "no resource groups to backfill"}
assert ScanGroupSummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id, resource_group="existing-group"
).exists()
def test_rerun_upserts_without_duplicating(self, findings_with_group_fixture):
"""Calling the backfill twice upserts rather than raising on
`unique_resource_group_severity_per_scan`; rows are updated in
place (same primary keys)."""
finding = findings_with_group_fixture
tenant_id = str(finding.tenant_id)
scan_id = str(finding.scan_id)
aggregate_scan_resource_group_summaries(tenant_id, scan_id)
first_ids = set(
ScanGroupSummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id
).values_list("id", flat=True)
)
aggregate_scan_resource_group_summaries(tenant_id, scan_id)
second_ids = set(
ScanGroupSummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id
).values_list("id", flat=True)
)
assert first_ids == second_ids
assert len(first_ids) == 1 # 1 resource group x 1 severity
def test_rerun_reflects_mute_between_runs(self, findings_with_group_fixture):
"""Muting a finding between two backfill runs must move counters:
`failed_findings` and `new_failed_findings` drop to zero (muted
findings are excluded from those totals). Guards against a
regression where the upsert keeps stale counts from the first run."""
finding = findings_with_group_fixture
tenant_id = str(finding.tenant_id)
scan_id = str(finding.scan_id)
aggregate_scan_resource_group_summaries(tenant_id, scan_id)
before = list(
ScanGroupSummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
)
assert len(before) == 1
assert before[0].failed_findings == 1
assert before[0].new_failed_findings == 1
assert before[0].total_findings == 1
Finding.all_objects.filter(pk=finding.pk).update(muted=True)
aggregate_scan_resource_group_summaries(tenant_id, scan_id)
after = list(
ScanGroupSummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
)
assert {s.id for s in after} == {s.id for s in before}
assert after[0].failed_findings == 0
assert after[0].new_failed_findings == 0
assert after[0].total_findings == 0
def test_not_completed_scan(self, get_not_completed_scans):
for scan in get_not_completed_scans:
result = backfill_scan_resource_group_summaries(
result = aggregate_scan_resource_group_summaries(
str(scan.tenant_id), str(scan.id)
)
assert result == {"status": "scan is not completed"}
def test_no_resource_groups_to_backfill(self, scans_fixture):
scan = scans_fixture[1] # Failed scan with no findings
result = backfill_scan_resource_group_summaries(
result = aggregate_scan_resource_group_summaries(
str(scan.tenant_id), str(scan.id)
)
assert result == {"status": "no resource groups to backfill"}
@@ -338,7 +469,7 @@ class TestBackfillScanGroupSummaries:
tenant_id = str(finding.tenant_id)
scan_id = str(finding.scan_id)
result = backfill_scan_resource_group_summaries(tenant_id, scan_id)
result = aggregate_scan_resource_group_summaries(tenant_id, scan_id)
# 1 resource group × 1 severity = 1 row
assert result == {"status": "backfilled", "resource_groups_count": 1}
+533 -1
View File
@@ -1,3 +1,5 @@
import os
import time
import uuid
from unittest.mock import Mock, patch
@@ -5,7 +7,12 @@ import matplotlib
import pytest
from reportlab.lib import colors
from tasks.jobs.report import (
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
STALE_TMP_OUTPUT_LOCK_FILE_NAME,
_cleanup_stale_tmp_output_directories,
_is_scan_directory_protected,
_pick_latest_cis_variant,
_should_run_stale_cleanup,
generate_compliance_reports,
generate_threatscore_report,
)
@@ -33,7 +40,13 @@ from tasks.jobs.threatscore_utils import (
_load_findings_for_requirement_checks,
)
from api.models import Finding, Resource, ResourceFindingMapping, StatusChoices
from api.models import (
Finding,
Resource,
ResourceFindingMapping,
StateChoices,
StatusChoices,
)
from prowler.lib.check.models import Severity
matplotlib.use("Agg") # Use non-interactive backend for tests
@@ -355,6 +368,366 @@ class TestLoadFindingsForChecks:
assert result == {}
class TestCleanupStaleTmpOutputDirectories:
"""Unit tests for opportunistic stale cleanup under tmp output root."""
def test_removes_only_scan_dirs_older_than_ttl(self, tmp_path, monkeypatch):
"""Should remove stale scan directories and keep recent ones."""
root_dir = tmp_path / "prowler_api_output"
old_scan_dir = root_dir / "tenant-a" / "scan-old"
old_scan_dir.mkdir(parents=True)
(old_scan_dir / "artifact.txt").write_text("old")
recent_scan_dir = root_dir / "tenant-a" / "scan-recent"
recent_scan_dir.mkdir(parents=True)
(recent_scan_dir / "artifact.txt").write_text("recent")
now = time.time()
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
os.utime(old_scan_dir, (stale_ts, stale_ts))
monkeypatch.setattr(
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
)
monkeypatch.setattr(
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
)
monkeypatch.setattr(
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
)
removed = _cleanup_stale_tmp_output_directories(
str(root_dir), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
)
assert removed == 1
assert not old_scan_dir.exists()
assert recent_scan_dir.exists()
def test_skips_current_scan_even_when_stale(self, tmp_path, monkeypatch):
"""Should not delete stale directory for the currently processed scan."""
root_dir = tmp_path / "prowler_api_output"
current_scan_dir = root_dir / "tenant-current" / "scan-current"
current_scan_dir.mkdir(parents=True)
(current_scan_dir / "artifact.txt").write_text("current")
other_stale_scan_dir = root_dir / "tenant-other" / "scan-old"
other_stale_scan_dir.mkdir(parents=True)
(other_stale_scan_dir / "artifact.txt").write_text("other")
now = time.time()
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
os.utime(current_scan_dir, (stale_ts, stale_ts))
os.utime(other_stale_scan_dir, (stale_ts, stale_ts))
monkeypatch.setattr(
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
)
monkeypatch.setattr(
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
)
monkeypatch.setattr(
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
)
removed = _cleanup_stale_tmp_output_directories(
str(root_dir),
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
exclude_scan=("tenant-current", "scan-current"),
)
assert removed == 1
assert current_scan_dir.exists()
assert not other_stale_scan_dir.exists()
def test_respects_max_deletions_per_run(self, tmp_path, monkeypatch):
"""Cleanup should stop deleting when max_deletions_per_run is reached."""
root_dir = tmp_path / "prowler_api_output"
stale_dir_1 = root_dir / "tenant-a" / "scan-old-1"
stale_dir_2 = root_dir / "tenant-a" / "scan-old-2"
stale_dir_1.mkdir(parents=True)
stale_dir_2.mkdir(parents=True)
(stale_dir_1 / "artifact.txt").write_text("old-1")
(stale_dir_2 / "artifact.txt").write_text("old-2")
now = time.time()
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
os.utime(stale_dir_1, (stale_ts, stale_ts))
os.utime(stale_dir_2, (stale_ts, stale_ts))
monkeypatch.setattr(
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
)
monkeypatch.setattr(
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
)
monkeypatch.setattr(
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
)
removed = _cleanup_stale_tmp_output_directories(
str(root_dir),
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
max_deletions_per_run=1,
)
assert removed == 1
remaining = sum(
1 for scan_dir in (stale_dir_1, stale_dir_2) if scan_dir.exists()
)
assert remaining == 1
def test_rejects_non_safe_root(self, tmp_path, monkeypatch):
"""Cleanup must no-op when called with a root outside the allowed safe root."""
root_dir = tmp_path / "prowler_api_output"
root_dir.mkdir(parents=True)
monkeypatch.setattr(
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT",
(tmp_path / "another-root").resolve(),
)
def _fail_should_run(*_args, **_kwargs):
raise AssertionError("_should_run_stale_cleanup should not be called")
monkeypatch.setattr(
"tasks.jobs.report._should_run_stale_cleanup", _fail_should_run
)
removed = _cleanup_stale_tmp_output_directories(str(root_dir), max_age_hours=48)
assert removed == 0
def test_ignores_symlink_scan_directories(self, tmp_path, monkeypatch):
"""Symlinked scan directories must never be deleted by cleanup."""
root_dir = tmp_path / "prowler_api_output"
stale_real_scan_dir = root_dir / "tenant-a" / "scan-old-real"
stale_real_scan_dir.mkdir(parents=True)
(stale_real_scan_dir / "artifact.txt").write_text("old")
symlink_target = tmp_path / "symlink-target"
symlink_target.mkdir(parents=True)
(symlink_target / "artifact.txt").write_text("target")
symlink_scan_dir = root_dir / "tenant-a" / "scan-link"
symlink_scan_dir.symlink_to(symlink_target, target_is_directory=True)
now = time.time()
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
os.utime(stale_real_scan_dir, (stale_ts, stale_ts))
monkeypatch.setattr(
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
)
monkeypatch.setattr(
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
)
monkeypatch.setattr(
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
)
removed = _cleanup_stale_tmp_output_directories(
str(root_dir), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
)
assert removed == 1
assert not stale_real_scan_dir.exists()
assert symlink_scan_dir.exists()
assert symlink_target.exists()
def test_handles_internal_exception_without_propagating(
self, tmp_path, monkeypatch
):
"""Cleanup errors must be swallowed so callers are not interrupted."""
root_dir = tmp_path / "prowler_api_output"
stale_scan_dir = root_dir / "tenant-a" / "scan-old"
stale_scan_dir.mkdir(parents=True)
now = time.time()
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
os.utime(stale_scan_dir, (stale_ts, stale_ts))
monkeypatch.setattr(
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
)
monkeypatch.setattr(
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
)
def _raise(*_args, **_kwargs):
raise RuntimeError("db timeout")
monkeypatch.setattr("tasks.jobs.report._is_scan_directory_protected", _raise)
removed = _cleanup_stale_tmp_output_directories(
str(root_dir), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
)
assert removed == 0
assert stale_scan_dir.exists()
def test_safe_root_follows_custom_tmp_output_directory(self, tmp_path, monkeypatch):
"""Custom DJANGO_TMP_OUTPUT_DIRECTORY must be honored as the safe root."""
from tasks.jobs import report as report_module
custom_root = tmp_path / "custom_tmp_output"
custom_root.mkdir(parents=True)
monkeypatch.setattr(
report_module, "DJANGO_TMP_OUTPUT_DIRECTORY", str(custom_root)
)
resolved_root = report_module._resolve_stale_tmp_safe_root()
assert resolved_root == custom_root.resolve()
stale_scan_dir = custom_root / "tenant-a" / "scan-old"
stale_scan_dir.mkdir(parents=True)
(stale_scan_dir / "artifact.txt").write_text("old")
stale_ts = time.time() - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
os.utime(stale_scan_dir, (stale_ts, stale_ts))
monkeypatch.setattr(report_module, "STALE_TMP_OUTPUT_SAFE_ROOT", resolved_root)
monkeypatch.setattr(
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
)
monkeypatch.setattr(
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
)
removed = _cleanup_stale_tmp_output_directories(
str(custom_root), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
)
assert removed == 1
assert not stale_scan_dir.exists()
@pytest.mark.parametrize(
"forbidden_root",
["/", "/tmp", "/var", "/var/tmp", "/home", "/root", "/etc", "/usr"],
)
def test_safe_root_rejects_forbidden_system_roots(
self, forbidden_root, monkeypatch
):
"""Cleanup must refuse to operate against shared system roots."""
from tasks.jobs import report as report_module
monkeypatch.setattr(
report_module, "DJANGO_TMP_OUTPUT_DIRECTORY", forbidden_root
)
assert report_module._resolve_stale_tmp_safe_root() is None
def test_skips_cleanup_when_safe_root_is_none(self, tmp_path, monkeypatch):
"""A None safe root (forbidden config) must short-circuit the cleanup."""
root_dir = tmp_path / "prowler_api_output"
root_dir.mkdir(parents=True)
monkeypatch.setattr("tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", None)
def _fail_should_run(*_args, **_kwargs):
raise AssertionError("_should_run_stale_cleanup should not be called")
monkeypatch.setattr(
"tasks.jobs.report._should_run_stale_cleanup", _fail_should_run
)
removed = _cleanup_stale_tmp_output_directories(
str(root_dir), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
)
assert removed == 0
class TestStaleCleanupProtectionHelpers:
"""Unit tests for stale cleanup helper guard logic."""
def test_should_run_cleanup_is_throttled(self, tmp_path):
root_dir = tmp_path / "prowler_api_output"
root_dir.mkdir(parents=True)
assert _should_run_stale_cleanup(root_dir, throttle_seconds=3600) is True
assert _should_run_stale_cleanup(root_dir, throttle_seconds=3600) is False
lock_file = root_dir / STALE_TMP_OUTPUT_LOCK_FILE_NAME
lock_file.write_text(str(int(time.time()) - 7200), encoding="ascii")
assert _should_run_stale_cleanup(root_dir, throttle_seconds=3600) is True
@patch("tasks.jobs.report.fcntl.flock", side_effect=BlockingIOError)
def test_should_run_cleanup_returns_false_when_lock_is_busy(
self, _mock_flock, tmp_path
):
root_dir = tmp_path / "prowler_api_output"
root_dir.mkdir(parents=True)
assert _should_run_stale_cleanup(root_dir, throttle_seconds=3600) is False
@patch("tasks.jobs.report.Scan.all_objects.using")
def test_is_scan_directory_protected_for_executing_scan(
self, mock_scan_using, tmp_path
):
scan_id = str(uuid.uuid4())
scan_path = tmp_path / scan_id
scan_path.mkdir(parents=True)
mock_scan_using.return_value.filter.return_value.only.return_value.first.return_value = Mock(
state=StateChoices.EXECUTING, output_location=None
)
assert (
_is_scan_directory_protected(
tenant_id="tenant-a",
scan_id=scan_id,
scan_path=scan_path,
)
is True
)
@patch("tasks.jobs.report.Scan.all_objects.using")
def test_is_scan_directory_protected_for_local_output(
self, mock_scan_using, tmp_path
):
scan_id = str(uuid.uuid4())
scan_path = tmp_path / scan_id
scan_path.mkdir(parents=True)
local_output_path = scan_path / "outputs.zip"
mock_scan_using.return_value.filter.return_value.only.return_value.first.return_value = Mock(
state=StateChoices.COMPLETED, output_location=str(local_output_path)
)
assert (
_is_scan_directory_protected(
tenant_id="tenant-a",
scan_id=scan_id,
scan_path=scan_path.resolve(),
)
is True
)
@patch("tasks.jobs.report.Scan.all_objects.using")
def test_is_scan_directory_not_protected_for_s3_output(
self, mock_scan_using, tmp_path
):
scan_id = str(uuid.uuid4())
scan_path = tmp_path / scan_id
scan_path.mkdir(parents=True)
mock_scan_using.return_value.filter.return_value.only.return_value.first.return_value = Mock(
state=StateChoices.COMPLETED,
output_location="s3://bucket/path/report.zip",
)
assert (
_is_scan_directory_protected(
tenant_id="tenant-a",
scan_id=scan_id,
scan_path=scan_path,
)
is False
)
@pytest.mark.django_db
class TestGenerateThreatscoreReportFunction:
"""Test suite for generate_threatscore_report function."""
@@ -426,6 +799,31 @@ class TestGenerateComplianceReportsOptimized:
mock_ens.assert_not_called()
mock_nis2.assert_not_called()
@patch(
"tasks.jobs.report._cleanup_stale_tmp_output_directories",
side_effect=RuntimeError("cleanup boom"),
)
def test_cleanup_exception_does_not_break_no_findings_flow(self, _mock_cleanup):
"""Unexpected cleanup failures must not abort report generation."""
random_tenant = str(uuid.uuid4())
random_scan = str(uuid.uuid4())
random_provider = str(uuid.uuid4())
with patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter:
mock_filter.return_value.exists.return_value = False
result = generate_compliance_reports(
tenant_id=random_tenant,
scan_id=random_scan,
provider_id=random_provider,
generate_threatscore=True,
generate_ens=False,
generate_nis2=False,
generate_csa=False,
generate_cis=False,
)
assert result["threatscore"] == {"upload": False, "path": ""}
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
def test_no_findings_returns_flat_cis_entry(
@@ -457,6 +855,103 @@ class TestGenerateComplianceReportsOptimized:
assert result["cis"] == {"upload": False, "path": ""}
mock_cis.assert_not_called()
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_threatscore_report")
@patch("tasks.jobs.report._generate_compliance_output_directory")
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
@patch("tasks.jobs.report.Compliance.get_bulk")
@patch("tasks.jobs.report.Provider.objects.get")
@patch("tasks.jobs.report.ScanSummary.objects.filter")
def test_cleanup_runs_when_supported_reports_upload_successfully(
self,
mock_scan_summary_filter,
mock_provider_get,
mock_get_bulk,
mock_aggregate_stats,
mock_generate_output_dir,
mock_threatscore,
mock_upload_to_s3,
mock_rmtree,
):
"""Cleanup must run when all generated (supported) reports are uploaded."""
mock_scan_summary_filter.return_value.exists.return_value = True
mock_provider_get.return_value = Mock(uid="provider-uid", provider="m365")
mock_get_bulk.return_value = {}
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = (
"/tmp/tenant/scan/threatscore/prowler-output-provider-20240101000000"
)
mock_upload_to_s3.return_value = (
"s3://bucket/tenant/scan/threatscore/report.pdf"
)
result = generate_compliance_reports(
tenant_id=str(uuid.uuid4()),
scan_id=str(uuid.uuid4()),
provider_id=str(uuid.uuid4()),
generate_threatscore=True,
generate_ens=True,
generate_nis2=True,
generate_csa=True,
generate_cis=True,
)
assert result["threatscore"]["upload"] is True
assert result["ens"]["upload"] is False
assert result["nis2"]["upload"] is False
assert result["csa"]["upload"] is False
assert result["cis"] == {"upload": False, "path": ""}
mock_generate_output_dir.assert_called_once()
mock_threatscore.assert_called_once()
mock_rmtree.assert_called_once()
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_threatscore_report")
@patch("tasks.jobs.report._generate_compliance_output_directory")
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
@patch("tasks.jobs.report.Compliance.get_bulk")
@patch("tasks.jobs.report.Provider.objects.get")
@patch("tasks.jobs.report.ScanSummary.objects.filter")
def test_cleanup_skipped_when_supported_upload_fails(
self,
mock_scan_summary_filter,
mock_provider_get,
mock_get_bulk,
mock_aggregate_stats,
mock_generate_output_dir,
mock_threatscore,
mock_upload_to_s3,
mock_rmtree,
):
"""Cleanup must not run when a generated report upload fails."""
mock_scan_summary_filter.return_value.exists.return_value = True
mock_provider_get.return_value = Mock(uid="provider-uid", provider="m365")
mock_get_bulk.return_value = {}
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = (
"/tmp/tenant/scan/threatscore/prowler-output-provider-20240101000000"
)
mock_upload_to_s3.return_value = None
result = generate_compliance_reports(
tenant_id=str(uuid.uuid4()),
scan_id=str(uuid.uuid4()),
provider_id=str(uuid.uuid4()),
generate_threatscore=True,
generate_ens=True,
generate_nis2=True,
generate_csa=True,
generate_cis=True,
)
assert result["threatscore"]["upload"] is False
assert result["cis"] == {"upload": False, "path": ""}
mock_generate_output_dir.assert_called_once()
mock_threatscore.assert_called_once()
mock_rmtree.assert_not_called()
@pytest.mark.django_db
class TestGenerateComplianceReportsCIS:
@@ -622,6 +1117,43 @@ class TestGenerateComplianceReportsCIS:
assert result["cis"] == {"upload": False, "path": ""}
mock_cis.assert_not_called()
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
@patch("tasks.jobs.report._generate_compliance_output_directory")
@patch("tasks.jobs.report.Compliance.get_bulk")
def test_cis_output_directory_failure_is_captured(
self,
mock_get_bulk,
mock_generate_output_dir,
mock_stats,
monkeypatch,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""CIS output dir errors must be captured in results (not raised)."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
self._force_scan_has_findings(monkeypatch)
mock_stats.return_value = {}
mock_get_bulk.return_value = {"cis_5.0_aws": Mock()}
mock_generate_output_dir.side_effect = RuntimeError("dir boom")
result = generate_compliance_reports(
tenant_id=str(tenant.id),
scan_id=str(scan.id),
provider_id=str(provider.id),
generate_threatscore=False,
generate_ens=False,
generate_nis2=False,
generate_csa=False,
generate_cis=True,
)
assert result["cis"]["upload"] is False
assert result["cis"]["error"] == "dir boom"
class TestPickLatestCisVariant:
"""Unit tests for `_pick_latest_cis_variant` helper."""
+135 -24
View File
@@ -3359,6 +3359,85 @@ class TestAggregateFindings:
regions = {s.region for s in summaries}
assert regions == {"us-east-1", "us-west-2"}
@patch("tasks.jobs.scan.Finding.objects.filter")
@patch("tasks.jobs.scan.ScanSummary.objects.bulk_create")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_findings_skips_rows_with_null_service_or_region(
self, mock_rls_transaction, mock_bulk_create, mock_findings_filter
):
"""Aggregation rows with NULL service or region (orphan Findings whose
ResourceFindingMapping is missing) must be dropped before
``bulk_create`` so the NOT NULL constraints on ``scan_summaries`` are
not violated. Valid rows in the same batch must still be persisted."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
base_counts = {
"fail": 1,
"_pass": 0,
"muted_count": 0,
"total": 1,
"new": 0,
"changed": 0,
"unchanged": 1,
"fail_new": 0,
"fail_changed": 0,
"pass_new": 0,
"pass_changed": 0,
"muted_new": 0,
"muted_changed": 0,
}
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{
"check_id": "check_valid",
"resources__service": "s3",
"severity": "high",
"resources__region": "us-east-1",
**base_counts,
},
{
"check_id": "check_null_service",
"resources__service": None,
"severity": "high",
"resources__region": "us-east-1",
**base_counts,
},
{
"check_id": "check_null_region",
"resources__service": "ec2",
"severity": "low",
"resources__region": None,
**base_counts,
},
{
"check_id": "check_null_both",
"resources__service": None,
"severity": "medium",
"resources__region": None,
**base_counts,
},
]
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
mock_findings_filter.return_value = mock_queryset
aggregate_findings(tenant_id, scan_id)
mock_bulk_create.assert_called_once()
args, _ = mock_bulk_create.call_args
summaries = list(args[0])
assert len(summaries) == 1
assert summaries[0].check_id == "check_valid"
assert summaries[0].service == "s3"
assert summaries[0].region == "us-east-1"
def test_aggregate_findings_is_idempotent_on_rerun(
self,
tenants_fixture,
@@ -3366,14 +3445,24 @@ class TestAggregateFindings:
findings_fixture,
):
"""Re-running `aggregate_findings` for the same scan must not violate
the `unique_scan_summary` constraint, and the resulting row set for
the scan must match the single-run output. This is exercised by the
post-mute reaggregation pipeline, which re-dispatches
`perform_scan_summary_task` against scans whose summaries already
exist."""
the `unique_scan_summary` constraint. The post-mute reaggregation
pipeline re-dispatches `perform_scan_summary_task` against scans
whose summaries already exist; upsert must update existing rows in
place (same primary keys) rather than inserting duplicates."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
value_columns = (
"check_id",
"service",
"severity",
"region",
"fail",
"_pass",
"muted",
"total",
)
aggregate_findings(str(tenant.id), str(scan.id))
first_run_ids = set(
ScanSummary.all_objects.filter(
@@ -3382,19 +3471,11 @@ class TestAggregateFindings:
)
first_run_rows = list(
ScanSummary.all_objects.filter(tenant_id=tenant.id, scan_id=scan.id).values(
"check_id",
"service",
"severity",
"region",
"fail",
"_pass",
"muted",
"total",
*value_columns
)
)
# Second invocation must not raise and must replace the rows without
# leaving duplicates behind.
# Second invocation must not raise and must not duplicate rows.
aggregate_findings(str(tenant.id), str(scan.id))
second_run_ids = set(
ScanSummary.all_objects.filter(
@@ -3403,19 +3484,49 @@ class TestAggregateFindings:
)
second_run_rows = list(
ScanSummary.all_objects.filter(tenant_id=tenant.id, scan_id=scan.id).values(
"check_id",
"service",
"severity",
"region",
"fail",
"_pass",
"muted",
"total",
*value_columns
)
)
# Upsert preserves the original row identities; values stay stable
# because the underlying Finding set is unchanged between runs.
assert second_run_rows == first_run_rows
assert first_run_ids.isdisjoint(second_run_ids)
assert first_run_ids == second_run_ids
def test_aggregate_findings_reflects_mute_between_runs(
self,
tenants_fixture,
scans_fixture,
findings_fixture,
):
"""Re-running `aggregate_findings` after a finding is muted between
runs must move counters: the matching ScanSummary row's `fail`
decrements and `muted` increments. Guards against a regression where
upsert silently keeps stale values from the first run."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
finding1, _ = findings_fixture # finding1 is FAIL and not muted.
aggregate_findings(str(tenant.id), str(scan.id))
before = ScanSummary.all_objects.get(
tenant_id=tenant.id,
scan_id=scan.id,
check_id=finding1.check_id,
service="ec2",
severity=finding1.severity,
region="us-east-1",
)
assert before.fail == 1
assert before.muted == 0
Finding.all_objects.filter(pk=finding1.pk).update(muted=True)
aggregate_findings(str(tenant.id), str(scan.id))
after = ScanSummary.all_objects.get(pk=before.pk)
assert after.fail == 0
assert after.muted == 1
assert after.total == before.total
@pytest.mark.django_db
+66 -13
View File
@@ -13,6 +13,8 @@ from tasks.jobs.lighthouse_providers import (
_extract_bedrock_credentials,
)
from tasks.tasks import (
DJANGO_TMP_OUTPUT_DIRECTORY,
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
_cleanup_orphan_scheduled_scans,
_perform_scan_complete_tasks,
check_integrations_task,
@@ -236,7 +238,8 @@ class TestGenerateOutputs:
self.provider_id = str(uuid.uuid4())
self.tenant_id = str(uuid.uuid4())
def test_no_findings_returns_early(self):
@patch("tasks.tasks._cleanup_stale_tmp_output_directories")
def test_no_findings_returns_early(self, mock_cleanup_stale_tmp_output_directories):
with patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter:
mock_filter.return_value.exists.return_value = False
@@ -248,6 +251,34 @@ class TestGenerateOutputs:
assert result == {"upload": False}
mock_filter.assert_called_once_with(scan_id=self.scan_id)
mock_cleanup_stale_tmp_output_directories.assert_called_once_with(
DJANGO_TMP_OUTPUT_DIRECTORY,
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
exclude_scan=(self.tenant_id, self.scan_id),
)
@patch(
"tasks.tasks._cleanup_stale_tmp_output_directories",
side_effect=RuntimeError("cleanup boom"),
)
def test_cleanup_exception_does_not_break_no_findings_flow(
self, mock_cleanup_stale_tmp_output_directories
):
with patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter:
mock_filter.return_value.exists.return_value = False
result = generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
)
assert result == {"upload": False}
mock_cleanup_stale_tmp_output_directories.assert_called_once_with(
DJANGO_TMP_OUTPUT_DIRECTORY,
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
exclude_scan=(self.tenant_id, self.scan_id),
)
@patch("tasks.tasks._upload_to_s3")
@patch("tasks.tasks._compress_output_files")
@@ -309,7 +340,7 @@ class TestGenerateOutputs:
),
patch(
"tasks.tasks.COMPLIANCE_CLASS_MAP",
{"aws": [(lambda x: True, MagicMock(name="CSVCompliance"))]},
{"aws": [(lambda _x: True, MagicMock(name="CSVCompliance"))]},
),
patch(
"tasks.tasks._generate_output_directory",
@@ -361,7 +392,7 @@ class TestGenerateOutputs:
),
patch(
"tasks.tasks.COMPLIANCE_CLASS_MAP",
{"aws": [(lambda x: True, MagicMock())]},
{"aws": [(lambda _x: True, MagicMock())]},
),
patch("tasks.tasks._compress_output_files", return_value="/tmp/compressed"),
patch("tasks.tasks._upload_to_s3", return_value=None),
@@ -441,7 +472,7 @@ class TestGenerateOutputs:
),
patch(
"tasks.tasks.COMPLIANCE_CLASS_MAP",
{"aws": [(lambda x: True, mock_compliance_class)]},
{"aws": [(lambda _x: True, mock_compliance_class)]},
),
):
mock_filter.return_value.exists.return_value = True
@@ -470,6 +501,10 @@ class TestGenerateOutputs:
class TrackingWriter:
def __init__(self, findings, file_path, file_extension, from_cli):
self.findings = findings
self.file_path = file_path
self.file_extension = file_extension
self.from_cli = from_cli
self.transform_called = 0
self.batch_write_data_to_file = MagicMock()
self._data = []
@@ -578,13 +613,13 @@ class TestGenerateOutputs:
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
patch(
"tasks.tasks.FindingOutput.transform_api_finding",
side_effect=lambda f, prov: f,
side_effect=lambda f, _prov: f,
),
patch("tasks.tasks._compress_output_files", return_value="outdir.zip"),
patch("tasks.tasks._upload_to_s3", return_value="s3://bucket/outdir.zip"),
patch(
"tasks.tasks.Scan.all_objects.filter",
return_value=MagicMock(update=lambda **kw: None),
return_value=MagicMock(update=lambda **_kw: None),
),
patch("tasks.tasks.batched", return_value=two_batches),
patch("tasks.tasks.OUTPUT_FORMATS_MAPPING", {}),
@@ -666,7 +701,7 @@ class TestGenerateOutputs:
),
patch(
"tasks.tasks.COMPLIANCE_CLASS_MAP",
{"aws": [(lambda x: True, mock_compliance_class)]},
{"aws": [(lambda _x: True, mock_compliance_class)]},
),
):
mock_filter.return_value.exists.return_value = True
@@ -748,7 +783,7 @@ class TestScanCompleteTasks:
@patch("tasks.tasks.can_provider_run_attack_paths_scan", return_value=False)
def test_scan_complete_tasks(
self,
mock_can_run_attack_paths,
_mock_can_run_attack_paths,
mock_attack_paths_task,
mock_check_integrations_task,
mock_compliance_reports_task,
@@ -994,7 +1029,7 @@ class TestCheckIntegrationsTask:
@patch("tasks.tasks.rmtree")
def test_generate_outputs_with_asff_for_aws_with_security_hub(
self,
mock_rmtree,
_mock_rmtree,
mock_scan_update,
mock_upload,
mock_compress,
@@ -1122,7 +1157,7 @@ class TestCheckIntegrationsTask:
@patch("tasks.tasks.rmtree")
def test_generate_outputs_no_asff_for_aws_without_security_hub(
self,
mock_rmtree,
_mock_rmtree,
mock_scan_update,
mock_upload,
mock_compress,
@@ -1245,7 +1280,7 @@ class TestCheckIntegrationsTask:
@patch("tasks.tasks.rmtree")
def test_generate_outputs_no_asff_for_non_aws_provider(
self,
mock_rmtree,
_mock_rmtree,
mock_scan_update,
mock_upload,
mock_compress,
@@ -2361,6 +2396,9 @@ class TestReaggregateAllFindingGroupSummaries:
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_attack_surface_task")
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@@ -2371,6 +2409,9 @@ class TestReaggregateAllFindingGroupSummaries:
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
mock_group,
mock_chain,
):
@@ -2383,8 +2424,8 @@ class TestReaggregateAllFindingGroupSummaries:
yesterday = today - timedelta(days=1)
mock_outer_group_result = MagicMock()
# The first `group()` call wraps the inner (severity, finding-group)
# parallel step; subsequent calls wrap the outer per-scan generator.
# The first `group()` call wraps the inner parallel step; subsequent
# calls wrap the outer per-scan generator.
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
@@ -2420,6 +2461,9 @@ class TestReaggregateAllFindingGroupSummaries:
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
):
assert task_mock.si.call_count == 3
dispatched = {
@@ -2433,6 +2477,9 @@ class TestReaggregateAllFindingGroupSummaries:
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_attack_surface_task")
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@@ -2443,6 +2490,9 @@ class TestReaggregateAllFindingGroupSummaries:
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
mock_group,
mock_chain,
):
@@ -2481,6 +2531,9 @@ class TestReaggregateAllFindingGroupSummaries:
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
):
task_mock.si.assert_called_once_with(
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
@@ -121,8 +121,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.24.0"
PROWLER_API_VERSION="5.24.0"
PROWLER_UI_VERSION="5.25.0"
PROWLER_API_VERSION="5.25.0"
```
<Note>
@@ -17,6 +17,7 @@ Prowler requests the following read-only OAuth 2.0 scopes:
| `https://www.googleapis.com/auth/admin.directory.user.readonly` | Read access to user accounts and their admin status |
| `https://www.googleapis.com/auth/admin.directory.domain.readonly` | Read access to domain information |
| `https://www.googleapis.com/auth/admin.directory.customer.readonly` | Read access to customer information (Customer ID) |
| `https://www.googleapis.com/auth/admin.directory.orgunit.readonly` | Read access to organizational unit hierarchy (identifies the root OU for policy filtering) |
| `https://www.googleapis.com/auth/cloud-identity.policies.readonly` | Read access to domain-level application policies (required for Calendar service checks) |
| `https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly` | Read access to admin roles and role assignments |
@@ -86,7 +87,7 @@ This JSON key grants access to your Google Workspace organization. Never commit
6. In the **OAuth scopes** field, enter the following scopes as a comma-separated list:
```
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.customer.readonly,https://www.googleapis.com/auth/cloud-identity.policies.readonly,https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.customer.readonly,https://www.googleapis.com/auth/admin.directory.orgunit.readonly,https://www.googleapis.com/auth/cloud-identity.policies.readonly,https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly
```
7. Click **Authorize**
+15 -5
View File
@@ -2,18 +2,31 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.25.0] (Prowler UNRELEASED)
## [5.26.0] (Prowler UNRELEASED)
### 🚀 Added
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
## [5.25.0] (Prowler v5.25.0)
### 🚀 Added
- `--repo-list-file` CLI flag for GitHub provider to load repositories from a file [(#10501)](https://github.com/prowler-cloud/prowler/pull/10501)
- SARIF output format for the IaC provider, enabling GitHub Code Scanning integration via `--output-formats sarif` [(#10626)](https://github.com/prowler-cloud/prowler/pull/10626)
- `repository_default_branch_dismisses_stale_reviews` check for GitHub provider to ensure stale pull request approvals are dismissed when new commits are pushed [(#10569)](https://github.com/prowler-cloud/prowler/pull/10569)
- Official Prowler GitHub Action (`prowler-cloud/prowler@5.25`) for running scans in GitHub workflows with optional `--push-to-cloud` and SARIF upload to GitHub Code Scanning [(#10872)](https://github.com/prowler-cloud/prowler/pull/10872)
- GitHub Actions service for scanning workflow security issues using zizmor [(#10607)](https://github.com/prowler-cloud/prowler/pull/10607)
- `secretsmanager_has_restrictive_resource_policy` check for AWS provider [(#6985)](https://github.com/prowler-cloud/prowler/pull/6985)
### 🐞 Fixed
- Alibaba Cloud CS service SDK compatibility, harden other services and improve documentation [(#10871)](https://github.com/prowler-cloud/prowler/pull/10871)
- AWS Organizations metadata retrieval for delegated administrator scans by using the assumed role session instead of the pre-assume credentials [(#10894)](https://github.com/prowler-cloud/prowler/pull/10894)
- `admincenter_groups_not_public_visibility` check for M365 provider evaluating Security and Distribution groups, now restricted to Microsoft 365 (Unified) groups per CIS M365 Foundations 1.2.1 [(#10899)](https://github.com/prowler-cloud/prowler/pull/10899)
- Google Workspace check reports now store the actual domain or account resource subject instead of `provider.identity` [(#10901)](https://github.com/prowler-cloud/prowler/pull/10901)
- `entra_users_mfa_capable` evaluating disabled guest accounts; CIS 5.2.3.4 only targets enabled member users [(#10785)](https://github.com/prowler-cloud/prowler/pull/10785)
### 🐞 Fixed
@@ -32,10 +45,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.24.1] (Prowler v5.24.1)
### 🚀 Added
- `--repo-list-file` CLI flag for GitHub provider to load repositories from a file [(#10501)](https://github.com/prowler-cloud/prowler/pull/10501)
### 🔄 Changed
- `msgraph-sdk` from 1.23.0 to 1.55.0 and `azure-mgmt-resource` from 23.3.0 to 24.0.0, removing `marshmallow` as is a transitively dev dependency [(#10733)](https://github.com/prowler-cloud/prowler/pull/10733)
@@ -76,6 +85,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `prowler image --registry-list` crashes with `AttributeError` because `ImageProvider.__init__` returns early before registering the global provider [(#10691)](https://github.com/prowler-cloud/prowler/pull/10691)
- Vercel firewall config handling for team-scoped projects and current API response shapes [(#10695)](https://github.com/prowler-cloud/prowler/pull/10695)
- 9 Gmail checks for Google Workspace provider (`gmail_mail_delegation_disabled`, `gmail_shortener_scanning_enabled`, `gmail_external_image_scanning_enabled`, `gmail_untrusted_link_warnings_enabled`, `gmail_pop_imap_access_disabled`, `gmail_auto_forwarding_disabled`, `gmail_per_user_outbound_gateway_disabled`, `gmail_enhanced_pre_delivery_scanning_enabled`, `gmail_comprehensive_mail_storage_enabled`) using the Cloud Identity Policy API [(#10683)](https://github.com/prowler-cloud/prowler/pull/10683)
---
+6 -4
View File
@@ -198,7 +198,8 @@ def prowler():
if compliance_framework:
args.output_formats.extend(compliance_framework)
# If no input compliance framework, set all, unless a specific service or check is input
elif default_execution:
# Skip for IAC and LLM providers that don't use compliance frameworks
elif default_execution and provider not in ["iac", "llm"]:
args.output_formats.extend(get_available_compliance_frameworks(provider))
# Set Logger configuration
@@ -432,14 +433,15 @@ def prowler():
findings = global_provider.run_scan(streaming_callback=streaming_callback)
else:
# Original behavior for IAC or non-verbose LLM
# Original behavior for IAC and Image
try:
findings = global_provider.run()
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
# Note: IaC doesn't support granular progress tracking since Trivy runs as a black box
# and returns all findings at once. Progress tracking would just be 0% → 100%.
# Note: External tool providers don't support granular progress tracking since
# they run external tools as a black box and return all findings at once.
# Progress tracking would just be 0% → 100%.
# Filter findings by status if specified
if hasattr(args, "status") and args.status:
@@ -525,7 +525,9 @@
{
"Id": "3.1.3.1.1",
"Description": "Ensure users cannot delegate access to their mailbox",
"Checks": [],
"Checks": [
"gmail_mail_delegation_disabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -714,7 +716,9 @@
{
"Id": "3.1.3.4.2.1",
"Description": "Ensure link identification behind shortened URLs is enabled",
"Checks": [],
"Checks": [
"gmail_shortener_scanning_enabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -735,7 +739,9 @@
{
"Id": "3.1.3.4.2.2",
"Description": "Ensure scan linked images for malicious content is enabled",
"Checks": [],
"Checks": [
"gmail_external_image_scanning_enabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -756,7 +762,9 @@
{
"Id": "3.1.3.4.2.3",
"Description": "Ensure warning prompt is shown for any click on links to untrusted domains",
"Checks": [],
"Checks": [
"gmail_untrusted_link_warnings_enabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -882,7 +890,9 @@
{
"Id": "3.1.3.5.1",
"Description": "Ensure POP and IMAP access is disabled for all users",
"Checks": [],
"Checks": [
"gmail_pop_imap_access_disabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -903,7 +913,9 @@
{
"Id": "3.1.3.5.2",
"Description": "Ensure automatic forwarding options are disabled",
"Checks": [],
"Checks": [
"gmail_auto_forwarding_disabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -924,7 +936,9 @@
{
"Id": "3.1.3.5.3",
"Description": "Ensure per-user outbound gateways is disabled",
"Checks": [],
"Checks": [
"gmail_per_user_outbound_gateway_disabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -966,7 +980,9 @@
{
"Id": "3.1.3.6.1",
"Description": "Ensure enhanced pre-delivery message scanning is enabled",
"Checks": [],
"Checks": [
"gmail_enhanced_pre_delivery_scanning_enabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1008,7 +1024,9 @@
{
"Id": "3.1.3.7.1",
"Description": "Ensure comprehensive mail storage is enabled",
"Checks": [],
"Checks": [
"gmail_comprehensive_mail_storage_enabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -556,7 +556,9 @@
{
"Id": "GWS.GMAIL.1.1",
"Description": "Mail Delegation SHOULD be disabled",
"Checks": [],
"Checks": [
"gmail_mail_delegation_disabled"
],
"Attributes": [
{
"Section": "Gmail",
@@ -725,7 +727,9 @@
{
"Id": "GWS.GMAIL.6.1",
"Description": "Identify links behind shortened URLs SHALL be enabled",
"Checks": [],
"Checks": [
"gmail_shortener_scanning_enabled"
],
"Attributes": [
{
"Section": "Gmail",
@@ -738,7 +742,9 @@
{
"Id": "GWS.GMAIL.6.2",
"Description": "Scan linked images SHALL be enabled",
"Checks": [],
"Checks": [
"gmail_external_image_scanning_enabled"
],
"Attributes": [
{
"Section": "Gmail",
@@ -751,7 +757,9 @@
{
"Id": "GWS.GMAIL.6.3",
"Description": "Show warning prompt for any click on links to untrusted domains SHALL be enabled",
"Checks": [],
"Checks": [
"gmail_untrusted_link_warnings_enabled"
],
"Attributes": [
{
"Section": "Gmail",
@@ -907,7 +915,9 @@
{
"Id": "GWS.GMAIL.9.1",
"Description": "POP and IMAP access SHALL be disabled to protect sensitive agency or organization emails from being accessed through legacy applications or other third-party mail clients",
"Checks": [],
"Checks": [
"gmail_pop_imap_access_disabled"
],
"Attributes": [
{
"Section": "Gmail",
@@ -933,7 +943,9 @@
{
"Id": "GWS.GMAIL.11.1",
"Description": "Automatic forwarding SHOULD be disabled, especially to external domains",
"Checks": [],
"Checks": [
"gmail_auto_forwarding_disabled"
],
"Attributes": [
{
"Section": "Gmail",
@@ -946,7 +958,9 @@
{
"Id": "GWS.GMAIL.12.1",
"Description": "Using a per-user outbound gateway that is a mail server other than the Google Workspace (GWS) mail servers SHALL be disabled",
"Checks": [],
"Checks": [
"gmail_per_user_outbound_gateway_disabled"
],
"Attributes": [
{
"Section": "Gmail",
@@ -985,7 +999,9 @@
{
"Id": "GWS.GMAIL.15.1",
"Description": "Enhanced pre-delivery message scanning SHALL be enabled to prevent phishing",
"Checks": [],
"Checks": [
"gmail_enhanced_pre_delivery_scanning_enabled"
],
"Attributes": [
{
"Section": "Gmail",
@@ -1037,7 +1053,9 @@
{
"Id": "GWS.GMAIL.17.1",
"Description": "Comprehensive mail storage SHOULD be enabled to allow information traceability across applications",
"Checks": [],
"Checks": [
"gmail_comprehensive_mail_storage_enabled"
],
"Attributes": [
{
"Section": "Gmail",
+11 -6
View File
@@ -11,6 +11,15 @@ import yaml
from packaging import version
from prowler.lib.check.compliance_models import load_compliance_framework_universal
# Re-exported from a leaf module so prowler.lib.check.utils can import the
# constant without participating in the config <-> compliance_models <-> utils
# import cycle. Existing consumers continue to import from this module.
# The `as EXTERNAL_TOOL_PROVIDERS` rename is the PEP 484 explicit re-export
# form so static analyzers (CodeQL, mypy, ruff) treat the name as public.
from prowler.lib.check.external_tool_providers import ( # noqa: F401
EXTERNAL_TOOL_PROVIDERS as EXTERNAL_TOOL_PROVIDERS,
)
from prowler.lib.logger import logger
@@ -40,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.25.0"
prowler_version = "5.26.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -70,10 +79,6 @@ class Provider(str, Enum):
VERCEL = "vercel"
# Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo)
# and bypass standard check/service loading.
EXTERNAL_TOOL_PROVIDERS = frozenset({"iac", "llm", "image"})
# Compliance
actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
@@ -191,7 +196,7 @@ def set_output_timestamp(
Override the global output timestamps so generated artifacts reflect a specific scan.
Returns the previous values so callers can restore them afterwards.
"""
global timestamp, timestamp_utc, output_file_timestamp, timestamp_iso
global output_file_timestamp, timestamp_iso
previous_values = (
timestamp.value,
+1
View File
@@ -141,6 +141,7 @@ aws:
# ]
organizations_enabled_regions: []
organizations_trusted_delegated_administrators: []
organizations_trusted_ids: []
# AWS ECR
# aws.ecr_repositories_scan_vulnerabilities_in_latest_image
+3 -2
View File
@@ -2,6 +2,7 @@ import sys
from colorama import Fore, Style
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.check.check import parse_checks_from_file
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.models import CheckMetadata, Severity
@@ -24,8 +25,8 @@ def load_checks_to_execute(
) -> set:
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
try:
# Bypass check loading for providers that use Trivy directly
if provider in ("iac", "image"):
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
return set()
# Local subsets
@@ -0,0 +1,7 @@
# Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo)
# and bypass standard check/service loading.
#
# Kept in a leaf module with no imports so it can be referenced from both
# prowler.config.config and prowler.lib.check.utils without forming an
# import cycle.
EXTERNAL_TOOL_PROVIDERS = frozenset({"iac", "llm", "image"})
+1
View File
@@ -4,6 +4,7 @@ import os
import sys
from pkgutil import walk_packages
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.logger import logger
+4 -4
View File
@@ -71,7 +71,7 @@ class ProwlerArgumentParser:
usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...",
epilog=f"""
Available Cloud Providers:
{{aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel{extra_providers_csv}}}
{{aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm{extra_providers_csv}}}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -83,12 +83,12 @@ Available Cloud Providers:
oraclecloud Oracle Cloud Infrastructure Provider
openstack OpenStack Provider
alibabacloud Alibaba Cloud Provider
iac IaC Provider (Beta)
iac IaC Provider
llm LLM Provider (Beta)
image Container Image Provider
nhn NHN Provider (Unofficial)
mongodbatlas MongoDB Atlas Provider (Beta)
vercel Vercel Provider{extra_providers_text}
mongodbatlas MongoDB Atlas Provider
vercel Vercel Provider
Available components:
dashboard Local dashboard
@@ -41,6 +41,9 @@ def display_compliance_table(
Returns:
None
"""
# Filter out findings with dynamic CheckIDs not present in bulk_checks_metadata
findings = [f for f in findings if f.check_metadata.CheckID in bulk_checks_metadata]
try:
if "ens_" in compliance_framework:
get_ens_table(
+32 -2
View File
@@ -318,8 +318,16 @@ class AwsProvider(Provider):
########
######## AWS Organizations Metadata
# This is needed in the case we don't assume an AWS Organizations IAM Role
aws_organizations_session = self._session.original_session
# Default to the current (post-assume) session so DescribeAccount runs
# with the same identity that performs the scan. This makes delegated
# administrator scenarios work without extra configuration: when the
# scan role itself sits in the management or delegated admin account,
# it already holds the Organizations permissions needed. The
# management-account -> member-account flow is handled by the
# original-session fallback below. Use `organizations_role_arn` to
# override when Organizations lives in a different account than both
# the scan role and the original credentials.
aws_organizations_session = self._session.current_session
# Get a new session if the organizations_role_arn is set
if organizations_role_arn:
# Validate the input role
@@ -364,6 +372,28 @@ class AwsProvider(Provider):
self._organizations_metadata = self.get_organizations_info(
aws_organizations_session, self._identity.account
)
# Fallback to the original (pre-assume) session when no explicit
# organizations_role_arn is set and the current session could not
# retrieve Organizations metadata. This preserves the
# management-account -> member-account flow, where DescribeAccount is
# only allowed from the management account or a delegated
# administrator and the assumed member-account session has no
# Organizations permissions.
if (
not organizations_role_arn
and self._session.current_session is not self._session.original_session
and (
self._organizations_metadata is None
or not self._organizations_metadata.organization_id
)
):
logger.info(
"Retrying AWS Organizations metadata retrieval with the original session"
)
self._organizations_metadata = self.get_organizations_info(
self._session.original_session, self._identity.account
)
########
# Get Enabled Regions
+3 -1
View File
@@ -1,4 +1,6 @@
import os
AWS_STS_GLOBAL_ENDPOINT_REGION = "us-east-1"
AWS_REGION_US_EAST_1 = "us-east-1"
BOTO3_USER_AGENT_EXTRA = "APN_1826889"
BOTO3_USER_AGENT_EXTRA = os.getenv("PROWLER_AWS_BOTO3_USER_AGENT_EXTRA", "APN_1826889")
ROLE_SESSION_NAME = "ProwlerAssessmentSession"
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "secretsmanager_has_restrictive_resource_policy",
"CheckTitle": "Secrets Manager secret has a restrictive resource-based policy",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Effects/Data Exposure"
],
"ServiceName": "secretsmanager",
"SubServiceName": "",
"ResourceIdTemplate": "arn:aws:secretsmanager:region:account-id:secret:secret-name",
"Severity": "high",
"ResourceType": "AwsSecretsManagerSecret",
"ResourceGroup": "security",
"Description": "**Secrets Manager secrets** are evaluated for **restrictive resource-based policies**: explicit **Deny** for unauthorized principals, **Organization** boundary via `PrincipalOrgID`, `aws:SourceAccount` for service access. Per-principal **NotAction** restrictions are optional (defense-in-depth). Regionalized service principals are supported.",
"Risk": "Without a restrictive resource policy, **any IAM principal** in the account—or even **cross-account entities**—can read, modify, or delete the secret, compromising **confidentiality** and **integrity**. Overly broad policies enable **lateral movement** and **privilege escalation** through exposed credentials.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/auth-and-access_resource-policies.html",
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/determine-acccess_examine-iam-policies.html"
],
"Remediation": {
"Code": {
"CLI": "aws secretsmanager put-resource-policy --secret-id <example_resource_id> --resource-policy file://policy.json",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::SecretsManager::ResourcePolicy\n Properties:\n SecretId: <example_resource_id>\n ResourcePolicy: # Critical: deny-by-default with explicit exceptions\n Version: '2012-10-17'\n Statement:\n - Effect: Deny\n Principal: '*'\n Action: '*'\n Resource: '*'\n Condition:\n StringNotEquals:\n aws:PrincipalArn: <AUTHORIZED_ROLE_ARN>\n```",
"Other": "1. Open AWS Console > Secrets Manager\n2. Select the secret > Overview tab > Resource permissions > Edit permissions\n3. Add a **Deny** statement for `Principal: *` with `StringNotEquals` condition listing only authorized `aws:PrincipalArn` values\n4. Add a **Deny** statement with `StringNotEquals` on `aws:PrincipalOrgID` to block access from outside your organization\n5. For each authorized principal, add a **Deny** with `NotAction` listing only the specific actions they need\n6. Save the policy",
"Terraform": "```hcl\nresource \"aws_secretsmanager_secret_policy\" \"<example_resource_name>\" {\n secret_arn = \"<example_resource_id>\"\n policy = jsonencode({ # Critical: deny-by-default with explicit exceptions\n Version = \"2012-10-17\"\n Statement = [\n {\n Effect = \"Deny\"\n Principal = \"*\"\n Action = \"*\"\n Resource = \"*\"\n Condition = {\n StringNotEquals = {\n \"aws:PrincipalArn\" = [\"<AUTHORIZED_ROLE_ARN>\"]\n }\n }\n }\n ]\n })\n}\n```"
},
"Recommendation": {
"Text": "Apply **deny-by-default** resource policies to every secret:\n- Deny all principals except explicitly authorized roles via `StringNotEquals` on `aws:PrincipalArn`\n- Deny access from outside the AWS Organization via `aws:PrincipalOrgID`\n- Constrain AWS service access with `aws:SourceAccount` (additional restrictive conditions like `ArnLike` are accepted)\n- Optionally, restrict each authorized principal to **least-privilege actions** using per-principal `Deny/NotAction` statements (defense-in-depth)",
"Url": "https://hub.prowler.com/check/secretsmanager_has_restrictive_resource_policy"
}
},
"Categories": [
"secrets",
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check enforces a strict deny-by-default pattern for Secrets Manager resource policies. It validates four layered controls: (1) an explicit Deny for all unauthorized principals, (2) an organization boundary via PrincipalOrgID, (3) per-principal action restrictions via NotAction (optional, validated only if present), and (4) SourceAccount constraints for AWS service principals (additional restrictive conditions are accepted). Cross-account Allow statements cause the check to fail intentionally to surface expanded trust boundaries for review. Both simple (e.g. appflow.amazonaws.com) and regionalized (e.g. logs.eu-central-1.amazonaws.com) service principals are supported."
}
@@ -0,0 +1,600 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.secretsmanager.secretsmanager_client import (
secretsmanager_client,
)
from prowler.providers.aws.services.iam.lib.policy import is_condition_block_restrictive
import re
class secretsmanager_has_restrictive_resource_policy(Check):
def execute(self):
findings = []
organizations_trusted_ids = secretsmanager_client.audit_config.get(
"organizations_trusted_ids", []
)
# Build partition-aware patterns (supports aws, aws-cn, aws-us-gov, etc.)
partition = re.escape(secretsmanager_client.audited_partition)
dns_suffix = re.escape(
".amazonaws.com.cn"
if secretsmanager_client.audited_partition == "aws-cn"
else ".amazonaws.com"
)
# Regular expression to match IAM roles or users without wildcard * in their name
arn_pattern = rf"arn:{partition}:iam::\d{{12}}:(role|user)/([^*]+)$"
# Regular expression to match AWS service names, including regionalized
# and multi-label principals (e.g. logs.eu-central-1.amazonaws.com)
service_pattern = rf"^[a-z0-9-]+(\.[a-z0-9-]+)*{dns_suffix}$"
# Regular expression to match any IAM ARN with account number
iam_arn_with_account_pattern = rf"arn:{partition}:iam::(\d{{12}}):"
# Regular expression to match IAM root account ARN
iam_root_arn_pattern = rf"arn:{partition}:iam::(\d{{12}}):root"
# Regular expression to match IAM role ARN with wildcard (at least 12 chars prefix before *)
arn_wildcard_pattern = rf"arn:{partition}:iam::\d{{12}}:role/.{{12,}}\*$"
# Maximum number of cross-account principals to display in error messages
max_principals_to_display = 3
for secret in secretsmanager_client.secrets.values():
report = Check_Report_AWS(self.metadata(), resource=secret)
report.region = secret.region
report.resource_id = secret.name
report.resource_arn = secret.arn
report.resource_tags = secret.tags
report.status = "FAIL"
# Determine the Role ARN to be used
assumed_role_config = getattr(
secretsmanager_client.provider, "_assumed_role_configuration", None
)
if (
assumed_role_config
and getattr(assumed_role_config, "info", None)
and getattr(assumed_role_config.info, "role_arn", None)
and getattr(assumed_role_config.info.role_arn, "arn", None)
):
final_role_arn = assumed_role_config.info.role_arn.arn
else:
identity_arn = secretsmanager_client.provider.identity.identity_arn
if identity_arn:
# If the identity ARN is a sts assumed-role ARN, transform it
sts_partition = re.escape(secretsmanager_client.audited_partition)
match = re.match(
rf"arn:{sts_partition}:sts::(\d+):assumed-role/([^/]+)/",
identity_arn,
)
if match:
account_id, role_name = match.groups()
final_role_arn = (
f"arn:{secretsmanager_client.audited_partition}"
f":iam::{account_id}:role/{role_name}"
)
else:
final_role_arn = identity_arn
else:
final_role_arn = "None"
report.status_extended = (
f"SecretsManager secret '{secret.name}' does not have a resource-based policy "
f"or access to the policy is denied for the role '{final_role_arn}'"
)
if secret.policy:
# Normalize Statement to a list (IAM spec allows a single dict)
statements = secret.policy.get("Statement", [])
if not isinstance(statements, list):
statements = [statements]
# Normalize condition keys to lowercase (IAM condition keys are case-insensitive)
statements = [
(
{
**s,
"Condition": self._normalize_condition_keys(s["Condition"]),
}
if "Condition" in s
else s
)
for s in statements
]
not_denied_principals = []
not_denied_services = []
arn_not_like_principals = [] # Store ARN patterns from ArnNotLike
# Check for an explicit Deny that applies to all Principals except those defined in the Condition
has_explicit_deny_for_all = False
# Track cross-account access detection
cross_account_principals = []
# Pass 1: Scan ALL Allow statements for cross-account principals
# This must be a separate pass to ensure order-independent evaluation
for statement in statements:
if statement.get("Effect") != "Allow":
continue
principals = self.extract_field(statement.get("Principal", {}))
for principal in principals:
if isinstance(principal, str):
match = re.match(iam_arn_with_account_pattern, principal)
if match:
principal_account = match.group(1)
if (
principal_account
!= secretsmanager_client.audited_account
):
cross_account_principals.append(principal)
elif principal == "*" or re.match(
iam_root_arn_pattern, principal
):
condition = statement.get("Condition", {})
if not condition or not is_condition_block_restrictive(
condition,
secretsmanager_client.audited_account,
is_cross_account_allowed=False,
):
cross_account_principals.append(principal)
# Pass 2: Validate Deny statements
for statement in statements:
if statement.get("Effect") != "Deny":
continue
principal = self.extract_field(statement.get("Principal", {}))
if "*" not in principal:
continue
actions = self.extract_field(statement.get("Action", []))
if not any(
action in ["*", "secretsmanager:*"] for action in actions
):
continue
if not self.is_valid_resource(
secret, self.extract_field(statement.get("Resource", "*"))
):
continue
condition = statement.get("Condition", {})
condition_principals = {}
if "StringNotEquals" in condition:
condition_principals = condition.get("StringNotEquals", {})
elif "StringNotEqualsIfExists" in condition:
condition_principals = condition.get(
"StringNotEqualsIfExists", {}
)
uses_principal_arn = "aws:principalarn" in condition_principals
uses_principal_service = (
"aws:principalservicename" in condition_principals
)
# Check for ArnNotLike condition
arn_not_like_condition = {}
uses_arn_not_like = False
if "ArnNotLike" in condition:
arn_not_like_condition = condition.get("ArnNotLike", {})
uses_arn_not_like = "aws:principalarn" in arn_not_like_condition
# Update valid keys to include ArnNotLike
valid_keys = {"aws:principalarn", "aws:principalservicename"}
if not set(condition_principals.keys()).issubset(valid_keys):
continue
# check values of principals
all_valid = True
for key, (not_denied_list, pattern) in {
"aws:principalarn": (not_denied_principals, arn_pattern),
"aws:principalservicename": (
not_denied_services,
service_pattern,
),
}.items():
if key in condition_principals:
if not self.is_valid_principal(
condition_principals[key], not_denied_list, pattern
):
all_valid = False
break
if not all_valid:
continue
# Validate ArnNotLike principals (must have at least 12 chars prefix before *)
if uses_arn_not_like:
arn_not_like_values = self.extract_field(
arn_not_like_condition.get("aws:principalarn", [])
)
for arn in arn_not_like_values:
if not re.match(arn_wildcard_pattern, arn):
all_valid = False
break
arn_not_like_principals.append(arn)
if not all_valid:
continue
# STRICT VALIDATION: Check that no additional condition operators exist
# that could weaken the policy (e.g., StringNotLike, etc.)
# case 1: both keys for Principal and Service exist - require IfExists + Null Condition
if uses_principal_arn and uses_principal_service:
# Allow ArnNotLike as additional condition operator
allowed_condition_operators = {
"StringNotEqualsIfExists",
"Null",
}
if uses_arn_not_like:
allowed_condition_operators.add("ArnNotLike")
if (
set(condition.keys()) == allowed_condition_operators
): # STRICT: no additional operators
null_condition = condition.get("Null", {})
# STRICT: Null condition must have exactly these two keys with value "true"
if null_condition == {
"aws:principalarn": "true",
"aws:principalservicename": "true",
}:
has_explicit_deny_for_all = True
break
# case 2: only PrincipalArn exists - require StringNotEquals (optionally with ArnNotLike)
elif uses_principal_arn and not uses_principal_service:
allowed_condition_operators = {"StringNotEquals"}
if uses_arn_not_like:
allowed_condition_operators.add("ArnNotLike")
if (
set(condition.keys()) == allowed_condition_operators
): # STRICT: no additional operators
has_explicit_deny_for_all = True
break
# Check for ArnLike statement that validates the wildcard principals
has_arn_like_validation = False
if arn_not_like_principals:
arn_like_values = []
# Look for all statements with ArnLike condition because they must match all the ArnNotLike principals
for statement in statements:
if statement.get("Effect") == "Deny":
condition = statement.get("Condition", {})
if "ArnLike" in condition:
arn_like_condition = condition.get("ArnLike", {})
if "aws:principalarn" in arn_like_condition:
arn_like_value = self.extract_field(
arn_like_condition.get("aws:principalarn", [])
)
arn_like_values.extend(arn_like_value)
# Check if all ArnNotLike principals are present in Deny-Statements with ArnLike Condition
if set(arn_not_like_principals) == set(
arn_like_values
):
has_arn_like_validation = True
break
else:
# No ArnNotLike principals, so no validation needed
has_arn_like_validation = True
# Check for Deny with "StringNotEquals":"aws:PrincipalOrgID" condition
has_deny_outside_org = (
True
if not organizations_trusted_ids
else any(
statement.get("Effect") == "Deny"
and "*" in self.extract_field(statement.get("Principal", {}))
and any(
action in ["*", "secretsmanager:*"]
for action in self.extract_field(
statement.get("Action", [])
)
)
and self.is_valid_resource(
secret, self.extract_field(statement.get("Resource", "*"))
)
and "Condition" in statement
and len(statement["Condition"])
== 1 # STRICT: only StringNotEquals, no additional operators
and "StringNotEquals" in statement["Condition"]
and "aws:principalorgid"
in statement["Condition"]["StringNotEquals"]
and all(
v in organizations_trusted_ids
for v in self.extract_field(
statement["Condition"]["StringNotEquals"][
"aws:principalorgid"
]
)
)
# STRICT: validate that StringNotEquals keys match exactly what is expected
and (
(
not not_denied_services
and set(
statement["Condition"]["StringNotEquals"].keys()
)
== {"aws:principalorgid"}
)
or (
not_denied_services
and set(
statement["Condition"]["StringNotEquals"].keys()
)
== {
"aws:principalorgid",
"aws:principalservicename",
}
and all(
s in not_denied_services
for s in self.extract_field(
statement["Condition"]["StringNotEquals"][
"aws:principalservicename"
]
)
)
)
)
for statement in statements
)
)
# Check for "NotActions" without wildcard * for not_denied_principals and not_denied_services.
# NOTE: Per-principal Deny/NotAction statements are an OPTIONAL hardening layer.
# The global Deny with StringNotEquals/aws:PrincipalArn already restricts access
# to only listed principals. The per-principal NotAction blocks further limit what
# each principal can do (defense-in-depth), but their absence does not cause a FAIL.
# They are only validated IF present - wildcards in NotAction are rejected.
failed_principals = []
failed_services = []
# Validate that NotAction does not contain wildcards for specified principals
for statement in statements:
if statement.get("Effect") == "Deny":
principals = self.extract_field(statement.get("Principal", {}))
# Check "NotAction" of Deny statements only for not_denied_principals
for principal in principals:
if principal in not_denied_principals:
if "NotAction" not in statement or any(
"*" in action
for action in self.extract_field(
statement.get("NotAction", [])
)
):
failed_principals.append(principal)
# Validate service-principal Allow statements
for statement in statements:
if statement.get("Effect") == "Allow":
principals = self.extract_field(statement.get("Principal", {}))
for service in principals:
if service in not_denied_services:
issues = self._validate_service_allow_statement(
statement,
secretsmanager_client.audited_account,
)
if issues:
failed_services.append(
{"service": service, "issues": issues}
)
has_specific_not_actions = len(failed_principals) == 0
has_valid_service_policies = len(failed_services) == 0
# Determine if the policy satisfies all conditions
if (
not cross_account_principals # No cross-account access via Allow statements
and has_explicit_deny_for_all
and has_deny_outside_org
and has_specific_not_actions
and has_valid_service_policies
and has_arn_like_validation
):
report.status = "PASS"
report.status_extended = f"SecretsManager secret '{secret.name}' has a sufficiently restrictive resource-based policy."
else:
report.status = "FAIL"
report.status_extended = f"SecretsManager secret '{secret.name}' does not meet all required restrictions: "
# Append detailed reasons for each failed condition
if cross_account_principals:
report.status_extended += (
f"Cross-account access detected - the following external principals have access: "
f"{', '.join(cross_account_principals[:max_principals_to_display])}"
f"{' and more...' if len(cross_account_principals) > max_principals_to_display else ''}. "
)
if not has_explicit_deny_for_all:
# Build a helpful error message showing which principals are expected
expected_parts = []
# Case 1: Only PrincipalArn exists -> StringNotEquals
if not_denied_principals and not not_denied_services:
principals_str = ", ".join(
not_denied_principals[:max_principals_to_display]
)
if len(not_denied_principals) > max_principals_to_display:
principals_str += " and more..."
expected_parts.append(
f"StringNotEquals with aws:PrincipalArn: {principals_str}"
)
# Case 2: Both PrincipalArn and PrincipalServiceName exist -> StringNotEqualsIfExists + Null
elif not_denied_principals and not_denied_services:
principals_str = ", ".join(
not_denied_principals[:max_principals_to_display]
)
if len(not_denied_principals) > max_principals_to_display:
principals_str += " and more..."
services_str = ", ".join(
not_denied_services[:max_principals_to_display]
)
if len(not_denied_services) > max_principals_to_display:
services_str += " and more..."
expected_parts.append(
f"StringNotEqualsIfExists with aws:PrincipalArn: {principals_str} and "
f"aws:PrincipalServiceName: {services_str}, plus Null condition for both keys with value 'true'"
)
# Case 3: Only PrincipalServiceName exists (edge case should never happen, but handle it)
elif not_denied_services and not not_denied_principals:
services_str = ", ".join(
not_denied_services[:max_principals_to_display]
)
if len(not_denied_services) > max_principals_to_display:
services_str += " and more..."
expected_parts.append(
f"StringNotEqualsIfExists with aws:PrincipalServiceName: {services_str}"
)
# Add ArnNotLike information if present
if arn_not_like_principals:
arns_str = ", ".join(
arn_not_like_principals[:max_principals_to_display]
)
if len(arn_not_like_principals) > max_principals_to_display:
arns_str += " and more..."
expected_parts.append(
f"ArnNotLike with aws:PrincipalArn: {arns_str}"
)
if expected_parts:
report.status_extended += f"Missing or incorrect 'Deny' statement for all Principals (expected conditions: {'; '.join(expected_parts)}). "
else:
report.status_extended += "Missing or incorrect 'Deny' statement for all Principals. "
if not has_deny_outside_org:
if not_denied_services:
report.status_extended += (
f"Missing or incorrect 'Deny' statement restricting access outside 'PrincipalOrgID'. "
f"The statement must also include 'aws:PrincipalServiceName' in StringNotEquals condition "
f"with the following service(s): {not_denied_services}. "
)
else:
report.status_extended += "Missing or incorrect 'Deny' statement restricting access outside 'PrincipalOrgID'. "
if not has_specific_not_actions:
report.status_extended += f"Missing field 'NotAction' or disallowed wildcard * in the 'NotAction' field of the 'Deny' statement for the specific Principal(s) {failed_principals if failed_principals else ''}. "
if not has_valid_service_policies:
# Build detailed error message for each failed service
service_errors = []
for failed_service in failed_services[
:max_principals_to_display
]:
service_name = failed_service["service"]
issues_str = ", ".join(failed_service["issues"])
service_errors.append(f"{service_name} ({issues_str})")
if len(failed_services) > max_principals_to_display:
remaining = len(failed_services) - max_principals_to_display
service_errors.append(f"and {remaining} more...")
report.status_extended += f"Invalid 'Allow' statements for Service Principals: {'; '.join(service_errors)}. "
if not has_arn_like_validation:
report.status_extended += f"Missing or incorrect 'ArnLike' validation statement for wildcard principals {arn_not_like_principals}. "
findings.append(report)
return findings
def _normalize_condition_keys(self, condition):
"""Normalize condition keys to lowercase for case-insensitive matching.
IAM condition key names are case-insensitive per AWS specification.
See: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition.html
"""
normalized = {}
for operator, keys_dict in condition.items():
if isinstance(keys_dict, dict):
normalized[operator] = {k.lower(): v for k, v in keys_dict.items()}
else:
normalized[operator] = keys_dict
return normalized
def _validate_service_allow_statement(self, statement, audited_account):
"""Validate a service-principal Allow statement.
Checks that the statement uses explicit Action (not NotAction),
does not use NotResource, contains no wildcards in Action, and
has a StringEquals condition with aws:SourceAccount matching the
audited account.
Returns a list of issues found, or an empty list if valid.
"""
issues = []
# Reject inverted elements that broaden scope
if "NotAction" in statement:
issues.append("uses NotAction instead of Action (too broad)")
if "NotResource" in statement:
issues.append("uses NotResource instead of Resource (too broad)")
if issues:
return issues
# Require explicit Action field
if "Action" not in statement:
issues.append("missing Action field")
else:
actions = self.extract_field(statement.get("Action", []))
if any(isinstance(action, str) and "*" in action for action in actions):
issues.append("contains wildcard in Action field")
# Validate condition: require at least StringEquals with aws:SourceAccount
# Additional restrictive conditions (e.g. ArnLike on aws:SourceArn) are acceptable.
# AWS allows condition values as scalar string or single-value list.
condition = statement.get("Condition", {})
source_account_values = self.extract_field(
condition.get("StringEquals", {}).get("aws:sourceaccount", [])
)
has_correct_condition = (
"StringEquals" in condition and audited_account in source_account_values
)
if not has_correct_condition:
if not condition:
issues.append("missing Condition block")
else:
issues.append(
f"incorrect Condition (expected: StringEquals with aws:SourceAccount={audited_account})"
)
return issues
# Extract values from a field to return an array containing the field,
# handling single values, arrays and dict with keys "AWS" or "Service".
# If the field is empty or invalid, return the default_value in the array.
def extract_field(self, field, default_value=None):
if isinstance(field, str):
return [field]
elif isinstance(field, list):
return field
elif isinstance(field, dict):
# Flatten all values from both AWS and Service keys
result = []
for key in ("AWS", "Service"):
if key in field:
if isinstance(field[key], str):
result.append(field[key])
else:
result.extend(field[key])
return result if result else [default_value]
return [default_value]
def is_valid_resource(self, secret, resource):
"""Check if the Resource field is valid for the given secret."""
if resource == "*":
return True # Wildcard resource is acceptable in general cases
if isinstance(resource, list):
if "*" in resource:
return True
return all(r == secret.arn for r in resource)
return resource == secret.arn
def is_valid_principal(self, principal_value, not_denied_list, pattern):
if not_denied_list is None or pattern is None:
return False
principals = self.extract_field(principal_value)
for principal in principals:
if re.match(pattern, principal):
not_denied_list.append(principal)
else:
return False
return True
+5
View File
@@ -426,6 +426,11 @@ class Provider(ABC):
repositories=repos,
repo_list_file=getattr(arguments, "repo_list_file", None),
organizations=orgs,
github_actions_enabled=not getattr(
arguments, "no_github_actions", False
),
exclude_workflows=getattr(arguments, "exclude_workflows", []),
fixer_config=fixer_config,
)
elif arguments.provider == "googleworkspace":
provider_class(
@@ -119,6 +119,9 @@ class GithubProvider(Provider):
repositories: list = None,
repo_list_file: str = None,
organizations: list = None,
# GitHub Actions scanning
github_actions_enabled: bool = True,
exclude_workflows: list = None,
):
"""
GitHub Provider constructor
@@ -210,8 +213,20 @@ class GithubProvider(Provider):
self._mutelist = GithubMutelist(
mutelist_path=mutelist_path,
)
# GitHub Actions scanning configuration
self._github_actions_enabled = github_actions_enabled
self._exclude_workflows = exclude_workflows or []
Provider.set_global_provider(self)
@property
def github_actions_enabled(self) -> bool:
return self._github_actions_enabled
@property
def exclude_workflows(self) -> list:
return self._exclude_workflows
@property
def auth_method(self):
"""Returns the authentication method for the GitHub provider."""
@@ -64,3 +64,20 @@ def init_parser(self):
default=None,
metavar="ORGANIZATION",
)
github_actions_subparser = github_parser.add_argument_group(
"GitHub Actions Scanning"
)
github_actions_subparser.add_argument(
"--no-github-actions",
action="store_true",
default=False,
help="Disable GitHub Actions workflow security scanning",
)
github_actions_subparser.add_argument(
"--exclude-workflows",
nargs="+",
default=[],
help="Workflow files or glob patterns to exclude from GitHub Actions scanning",
metavar="PATTERN",
)
@@ -0,0 +1,6 @@
from prowler.providers.common.provider import Provider
from prowler.providers.github.services.githubactions.githubactions_service import (
GithubActions,
)
githubactions_client = GithubActions(Provider.get_global_provider())
@@ -0,0 +1,273 @@
import io
import json
import shutil
import subprocess
import tempfile
from fnmatch import fnmatch
from os.path import basename
from typing import Optional
from dulwich import porcelain
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.github.github_provider import GithubProvider
from prowler.providers.github.lib.service.service import GithubService
class GithubActions(GithubService):
def __init__(self, provider: GithubProvider):
super().__init__(__class__.__name__, provider)
self.findings: dict[int, list[GithubActionsWorkflowFinding]] = {}
self.scan_enabled = False
if not getattr(provider, "github_actions_enabled", True):
logger.info(
"GitHub Actions scanning is disabled via --no-github-actions flag."
)
return
if not shutil.which("zizmor"):
logger.warning(
"zizmor binary not found. Skipping GitHub Actions workflow security scanning. "
"Install zizmor from https://github.com/woodruffw/zizmor"
)
return
self.scan_enabled = True
self._scan_repositories(provider)
def _scan_repositories(self, provider: GithubProvider):
from prowler.providers.github.services.repository.repository_client import (
repository_client,
)
exclude_workflows = getattr(provider, "exclude_workflows", []) or []
for repo_id, repo in repository_client.repositories.items():
temp_dir = None
try:
temp_dir = self._clone_repository(
f"https://github.com/{repo.full_name}",
provider.session.token,
)
if not temp_dir:
continue
raw_findings = self._run_zizmor(temp_dir)
repo_findings = []
for finding in raw_findings:
for location in finding.get("locations", []):
workflow_file = self._extract_workflow_file_from_location(
location
)
if not workflow_file:
continue
if workflow_file.startswith(temp_dir):
workflow_file = workflow_file[len(temp_dir) :].lstrip("/")
if self._should_exclude_workflow(
workflow_file, exclude_workflows
):
continue
parsed = self._parse_finding(
finding, workflow_file, location, repo
)
if parsed:
repo_findings.append(parsed)
self.findings[repo_id] = repo_findings
except Exception as error:
logger.error(
f"Error scanning repository {repo.full_name}: "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
finally:
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
def _clone_repository(
self, repository_url: str, token: str = None
) -> Optional[str]:
try:
auth_url = repository_url
if token:
auth_url = repository_url.replace(
"https://github.com/",
f"https://{token}@github.com/",
)
temp_dir = tempfile.mkdtemp()
logger.info(f"Cloning repository {repository_url} into {temp_dir}...")
porcelain.clone(auth_url, temp_dir, depth=1, errstream=io.BytesIO())
return temp_dir
except Exception as error:
error_msg = str(error)
if token:
error_msg = error_msg.replace(token, "***")
logger.error(
f"Failed to clone {repository_url}: "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_msg}"
)
return None
def _run_zizmor(self, directory: str) -> list[dict]:
try:
process = subprocess.run(
["zizmor", directory, "--format", "json"],
capture_output=True,
text=True,
timeout=1800,
)
if process.stderr:
for line in process.stderr.strip().split("\n"):
if line.strip():
logger.debug(f"zizmor: {line}")
if not process.stdout:
return []
output = json.loads(process.stdout)
if not output or (isinstance(output, list) and len(output) == 0):
return []
return output
except json.JSONDecodeError as error:
logger.warning(f"Failed to parse zizmor output as JSON: {error}")
return []
except Exception as error:
logger.error(
f"Error running zizmor: "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return []
@staticmethod
def _should_exclude_workflow(
workflow_file: str, exclude_patterns: list[str]
) -> bool:
if not exclude_patterns:
return False
filename = basename(workflow_file)
for pattern in exclude_patterns:
if fnmatch(workflow_file, pattern):
logger.debug(
f"Excluding workflow {workflow_file} (matches full path pattern: {pattern})"
)
return True
if fnmatch(filename, pattern):
logger.debug(
f"Excluding workflow {workflow_file} (matches filename pattern: {pattern})"
)
return True
return False
@staticmethod
def _extract_workflow_file_from_location(location: dict) -> Optional[str]:
try:
symbolic = location.get("symbolic", {})
if "key" in symbolic:
key = symbolic["key"]
if isinstance(key, dict) and "Local" in key:
local = key["Local"]
if isinstance(local, dict) and "given_path" in local:
return local["given_path"]
logger.debug(f"Could not extract workflow file from location: {location}")
return None
except Exception as error:
logger.error(
f"Error extracting workflow file from location: "
f"{error.__class__.__name__} - {error}"
)
return None
@staticmethod
def _parse_finding(
finding: dict, workflow_file: str, location: dict, repo
) -> Optional["GithubActionsWorkflowFinding"]:
try:
concrete_location = location.get("concrete", {}).get("location", {})
start = concrete_location.get("start_point", {})
end = concrete_location.get("end_point", {})
if start and end:
if start.get("row") == end.get("row"):
line_range = f"line {start.get('row', 'unknown')}"
else:
line_range = f"lines {start.get('row', 'unknown')}-{end.get('row', 'unknown')}"
else:
line_range = "location unknown"
determinations = finding.get("determinations", {})
severity = determinations.get("severity", "Unknown").lower()
confidence = determinations.get("confidence", "Unknown")
severity_map = {
"critical": "critical",
"high": "high",
"medium": "medium",
"low": "low",
"informational": "informational",
"unknown": "medium",
}
default_branch = getattr(
getattr(repo, "default_branch", None), "name", "main"
)
workflow_url = f"https://github.com/{repo.full_name}/blob/{default_branch}/{workflow_file}"
ident = finding.get("ident", "unknown")
return GithubActionsWorkflowFinding(
repo_id=repo.id,
repo_name=repo.name,
repo_full_name=repo.full_name,
repo_owner=repo.owner,
workflow_file=workflow_file,
workflow_url=workflow_url,
line_range=line_range,
finding_id=f"githubactions_{ident.replace('-', '_')}",
ident=ident,
description=finding.get(
"desc", "Security issue detected in GitHub Actions workflow"
),
severity=severity_map.get(severity, "medium"),
confidence=confidence,
annotation=location.get("symbolic", {}).get(
"annotation", "No details available"
),
url=finding.get("url", "https://docs.zizmor.sh/"),
)
except Exception as error:
logger.error(
f"Error parsing zizmor finding: "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
class GithubActionsWorkflowFinding(BaseModel):
repo_id: int
repo_name: str
repo_full_name: str
repo_owner: str
workflow_file: str
workflow_url: str
line_range: str
finding_id: str
ident: str
description: str
severity: str
confidence: str
annotation: str
url: str
@@ -0,0 +1,36 @@
{
"Provider": "github",
"CheckID": "githubactions_workflow_security_scan",
"CheckTitle": "GitHub Actions workflows have no security issues detected by zizmor",
"CheckType": [],
"ServiceName": "githubactions",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "GitHubActionsWorkflow",
"ResourceGroup": "devops",
"Description": "Scan GitHub Actions workflow files for security issues such as template injection, excessive permissions, unpinned actions, and other misconfigurations using the zizmor static analysis tool.",
"Risk": "Insecure GitHub Actions workflows can lead to supply chain attacks, credential theft, code injection, and unauthorized access to repository secrets.",
"RelatedUrl": "",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "Review and fix the security issues identified by zizmor in your GitHub Actions workflow files.",
"Terraform": ""
},
"Recommendation": {
"Text": "Review your GitHub Actions workflows for security best practices including pinning actions to commit SHAs, using minimal permissions, avoiding template injection, and securing workflow triggers.",
"Url": "https://hub.prowler.com/check/githubactions_workflow_security_scan"
}
},
"Categories": [
"software-supply-chain"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires zizmor to be installed. If zizmor is not available, the check will be skipped gracefully.",
"AdditionalURLs": [
"https://docs.zizmor.sh/"
]
}
@@ -0,0 +1,82 @@
import json
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.providers.github.services.githubactions.githubactions_client import (
githubactions_client,
)
from prowler.providers.github.services.repository.repository_client import (
repository_client,
)
class githubactions_workflow_security_scan(Check):
def execute(self) -> List[CheckReportGithub]:
findings = []
if not githubactions_client.scan_enabled:
return findings
for repo_id, repo in repository_client.repositories.items():
repo_findings = githubactions_client.findings.get(repo_id, [])
if not repo_findings:
report = CheckReportGithub(
metadata=self.metadata(),
resource=repo,
)
report.status = "PASS"
report.status_extended = f"Repository {repo.name} has no GitHub Actions workflow security issues detected by zizmor."
findings.append(report)
else:
for f in repo_findings:
metadata_dict = {
"Provider": "github",
"CheckID": f.finding_id,
"CheckTitle": f"GitHub Actions workflows free of {f.ident} issues",
"CheckType": [],
"ServiceName": "githubactions",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": f.severity,
"ResourceType": "GitHubActionsWorkflow",
"ResourceGroup": "devops",
"Description": f.description[:400],
"Risk": f.description[:400],
"RelatedUrl": "",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "",
},
"Recommendation": {
"Text": f"Review the zizmor documentation for {f.ident}: {f.url}",
"Url": "",
},
},
"Categories": ["software-supply-chain"],
"DependsOn": [],
"RelatedTo": [],
"Notes": "",
"AdditionalURLs": [f.url] if f.url else [],
}
report = CheckReportGithub(
metadata=json.dumps(metadata_dict),
resource=repo,
resource_name=f.workflow_file,
resource_id=str(f.repo_id),
owner=f.repo_owner,
)
report.status = "FAIL"
report.status_extended = (
f"GitHub Actions security issue in {f.workflow_file} at {f.line_range}: "
f"{f.description}. "
f"Confidence: {f.confidence}. "
f"Details: {f.annotation}. "
f"URL: {f.workflow_url}"
)
findings.append(report)
return findings
@@ -31,6 +31,7 @@ from prowler.providers.googleworkspace.lib.mutelist.mutelist import (
)
from prowler.providers.googleworkspace.models import (
GoogleWorkspaceIdentityInfo,
GoogleWorkspaceResource,
GoogleWorkspaceSession,
)
@@ -55,6 +56,7 @@ class GoogleworkspaceProvider(Provider):
_type: str = "googleworkspace"
_session: GoogleWorkspaceSession
_identity: GoogleWorkspaceIdentityInfo
_domain_resource: GoogleWorkspaceResource
_audit_config: dict
_mutelist: GoogleWorkspaceMutelist
audit_metadata: Audit_Metadata
@@ -64,6 +66,7 @@ class GoogleworkspaceProvider(Provider):
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.domain.readonly",
"https://www.googleapis.com/auth/admin.directory.customer.readonly",
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
# Cloud Identity Policy API (calendar and other app policies)
"https://www.googleapis.com/auth/cloud-identity.policies.readonly",
"https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly",
@@ -111,6 +114,7 @@ class GoogleworkspaceProvider(Provider):
self._session,
resolved_delegated_user,
)
self._domain_resource = GoogleWorkspaceResource.from_identity(self._identity)
# Audit Config
if config_content:
@@ -152,6 +156,12 @@ class GoogleworkspaceProvider(Provider):
"""Returns the type of the Google Workspace provider."""
return self._type
@property
def domain_resource(self) -> GoogleWorkspaceResource:
"""Returns the domain-level resource for account-wide checks."""
return self._domain_resource
@property
def audit_config(self):
return self._audit_config
@@ -449,10 +459,37 @@ class GoogleworkspaceProvider(Provider):
message=f"Delegated user domain {user_domain} is not configured in this Google Workspace. Valid domains: {', '.join(valid_domains)}. Ensure the delegated user belongs to the correct workspace or domain alias.",
)
# Fetch root org unit ID for policy filtering
# The Cloud Identity Policy API scopes all policies to an OU;
# the root OU is equivalent to customer-level.
root_org_unit_id = None
try:
orgunits_response = (
service.orgunits()
.list(
customerId=customer_id,
orgUnitPath="/",
type="allIncludingParent",
)
.execute()
)
for ou in orgunits_response.get("organizationUnits", []):
if ou.get("orgUnitPath") == "/":
root_org_unit_id = (
ou.get("orgUnitId", "").removeprefix("id:") or None
)
break
except Exception as error:
logger.warning(
f"Could not fetch root org unit: {error}. "
"Policy filtering will fall back to strict customer-level only."
)
identity = GoogleWorkspaceIdentityInfo(
domain=user_domain,
customer_id=customer_id,
delegated_user=delegated_user,
root_org_unit_id=root_org_unit_id,
profile="default",
)
@@ -13,6 +13,7 @@ class GoogleWorkspaceService:
provider: GoogleworkspaceProvider,
):
self.provider = provider
self.domain_resource = provider.domain_resource
self.audit_config = provider.audit_config
self.fixer_config = provider.fixer_config
self.credentials = provider.session.credentials
@@ -41,21 +42,26 @@ class GoogleWorkspaceService:
)
return None
@staticmethod
def _is_customer_level_policy(policy: dict) -> bool:
def _is_customer_level_policy(self, policy: dict) -> bool:
"""Check if a policy applies at the customer (domain-wide) level.
The Cloud Identity Policy API returns policies at multiple
organizational levels (customer, OU, group). Customer-level
policies have no group targeting and no sub-OU targeting in
their policyQuery.
The Cloud Identity Policy API typically scopes all policies to an OU;
absence of orgUnit is treated as customer-level as a safety net.
The root OU is equivalent to customer-level. This method accepts
policies with no orgUnit or policies targeting the root OU,
and rejects group-targeted and sub-OU policies.
"""
policy_query = policy.get("policyQuery", {})
if policy_query.get("group"):
return False
if policy_query.get("orgUnit"):
return False
return True
org_unit = policy_query.get("orgUnit")
if not org_unit:
return True
# Accept root OU as customer-level
root_id = getattr(self.provider.identity, "root_org_unit_id", None)
if root_id and org_unit == f"orgUnits/{root_id}":
return True
return False
def _handle_api_error(self, error, context: str, resource_name: str = ""):
"""
@@ -22,9 +22,44 @@ class GoogleWorkspaceIdentityInfo(BaseModel):
domain: str
customer_id: str
delegated_user: str
root_org_unit_id: Optional[str] = None
profile: Optional[str] = "default"
class GoogleWorkspaceResource(BaseModel):
"""Generic Google Workspace resource used by findings."""
id: str
customer_id: str
location: str = "global"
name: Optional[str] = None
email: Optional[str] = None
@classmethod
def from_identity(
cls, identity: "GoogleWorkspaceIdentityInfo"
) -> "GoogleWorkspaceResource":
"""Build the domain-level resource from provider identity."""
return cls(
id=identity.customer_id,
name=identity.domain,
customer_id=identity.customer_id,
)
@classmethod
def from_user(
cls, user: BaseModel | object, customer_id: str
) -> "GoogleWorkspaceResource":
"""Build a user-level resource from a Google Workspace user object."""
return cls(
id=getattr(user, "id", ""),
email=getattr(user, "email", ""),
customer_id=customer_id,
)
class GoogleWorkspaceOutputOptions(ProviderOutputOptions):
"""Google Workspace specific output options"""
@@ -20,11 +20,7 @@ class calendar_external_invitations_warning(Check):
if calendar_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=calendar_client.provider.identity,
resource_name=calendar_client.provider.identity.domain,
resource_id=calendar_client.provider.identity.customer_id,
customer_id=calendar_client.provider.identity.customer_id,
location="global",
resource=calendar_client.provider.domain_resource,
)
warning_enabled = calendar_client.policies.external_invitations_warning
@@ -20,11 +20,7 @@ class calendar_external_sharing_primary_calendar(Check):
if calendar_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=calendar_client.provider.identity,
resource_name=calendar_client.provider.identity.domain,
resource_id=calendar_client.provider.identity.customer_id,
customer_id=calendar_client.provider.identity.customer_id,
location="global",
resource=calendar_client.provider.domain_resource,
)
sharing = calendar_client.policies.primary_calendar_external_sharing
@@ -20,11 +20,7 @@ class calendar_external_sharing_secondary_calendar(Check):
if calendar_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=calendar_client.provider.identity,
resource_name=calendar_client.provider.identity.domain,
resource_id=calendar_client.provider.identity.customer_id,
customer_id=calendar_client.provider.identity.customer_id,
location="global",
resource=calendar_client.provider.domain_resource,
)
sharing = calendar_client.policies.secondary_calendar_external_sharing
@@ -23,11 +23,7 @@ class directory_super_admin_count(Check):
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=directory_client.provider.identity,
resource_name=directory_client.provider.identity.domain,
resource_id=directory_client.provider.identity.customer_id,
customer_id=directory_client.provider.identity.customer_id,
location="global",
resource=directory_client.provider.domain_resource,
)
if 2 <= admin_count <= 4:
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.models import GoogleWorkspaceResource
from prowler.providers.googleworkspace.services.directory.directory_client import (
directory_client,
)
@@ -18,7 +19,6 @@ class directory_super_admin_only_admin_roles(Check):
findings = []
if directory_client.users:
dual_role_admins = {}
for user in directory_client.users.values():
if user.is_admin:
extra_roles = [
@@ -27,34 +27,32 @@ class directory_super_admin_only_admin_roles(Check):
if not r.is_super_admin_role
]
if extra_roles:
dual_role_admins[user.email] = extra_roles
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=GoogleWorkspaceResource.from_user(
user,
directory_client.provider.identity.customer_id,
),
)
details = ", ".join(extra_roles)
report.status = "FAIL"
report.status_extended = (
f"Super admin account {user.email} also holds additional admin roles: "
f"{details}. Super admin accounts should be used only for "
f"super admin activities."
)
findings.append(report)
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=directory_client.provider.identity,
resource_name=directory_client.provider.identity.domain,
resource_id=directory_client.provider.identity.customer_id,
customer_id=directory_client.provider.identity.customer_id,
location="global",
)
if dual_role_admins:
details = ", ".join(
f"{email} ({', '.join(roles)})"
for email, roles in dual_role_admins.items()
if not findings:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=directory_client.provider.domain_resource,
)
report.status = "FAIL"
report.status_extended = (
f"Super admin accounts also holding additional admin roles: {details}. "
f"Super admin accounts should be used only for super admin activities."
)
else:
report.status = "PASS"
report.status_extended = (
f"All super admin accounts in domain {directory_client.provider.identity.domain} "
f"are used only for super admin activities."
)
findings.append(report)
findings.append(report)
return findings
@@ -19,11 +19,7 @@ class drive_access_checker_recipients_only(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
access_checker = drive_client.policies.access_checker_suggestions
@@ -20,11 +20,7 @@ class drive_desktop_access_disabled(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
allow_desktop = drive_client.policies.allow_drive_for_desktop
@@ -18,11 +18,7 @@ class drive_external_sharing_warn_users(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
warning_enabled = drive_client.policies.warn_for_external_sharing
@@ -19,11 +19,7 @@ class drive_internal_users_distribute_content(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
allowed = drive_client.policies.allowed_parties_for_distributing_content
@@ -19,11 +19,7 @@ class drive_publishing_files_disabled(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
allow_publishing = drive_client.policies.allow_publishing_files
@@ -20,11 +20,7 @@ class drive_shared_drive_creation_allowed(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
allow_creation = drive_client.policies.allow_shared_drive_creation
@@ -19,11 +19,7 @@ class drive_shared_drive_disable_download_print_copy(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
allowed = drive_client.policies.allowed_parties_for_download_print_copy
@@ -19,11 +19,7 @@ class drive_shared_drive_managers_cannot_override(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
allow_override = drive_client.policies.allow_managers_to_override_settings
@@ -19,11 +19,7 @@ class drive_shared_drive_members_only_access(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
allow_non_member = drive_client.policies.allow_non_member_access
@@ -18,11 +18,7 @@ class drive_sharing_allowlisted_domains(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
mode = drive_client.policies.external_sharing_mode
@@ -19,11 +19,7 @@ class drive_warn_sharing_with_allowlisted_domains(Check):
if drive_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=drive_client.provider.identity,
resource_name=drive_client.provider.identity.domain,
resource_id=drive_client.provider.identity.customer_id,
customer_id=drive_client.provider.identity.customer_id,
location="global",
resource=drive_client.provider.domain_resource,
)
warn_enabled = (
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_auto_forwarding_disabled",
"CheckTitle": "Automatic forwarding options are disabled",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Automatic email forwarding allows users to automatically forward all incoming email to an external address. Disabling this feature prevents unauthorized data exfiltration through email forwarding rules.",
"Risk": "With auto-forwarding enabled, an attacker who gains control of a user account can create **forwarding rules to exfiltrate** all incoming email to an external address. This can persist undetected and provide the attacker with continuous access to sensitive communications even after the account is recovered.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/2491924",
"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** > **Gmail**\n3. Click **End User Access** > **Automatic forwarding**\n4. Uncheck **Allow users to automatically forward incoming email to another address**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable automatic email forwarding to prevent users and attackers from setting up rules that exfiltrate email data to external addresses.",
"Url": "https://hub.prowler.com/check/gmail_auto_forwarding_disabled"
}
},
"Categories": [
"email-security"
],
"DependsOn": [],
"RelatedTo": [
"gmail_pop_imap_access_disabled",
"gmail_per_user_outbound_gateway_disabled"
],
"Notes": ""
}
@@ -0,0 +1,49 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_auto_forwarding_disabled(Check):
"""Check that automatic forwarding options are disabled.
This check verifies that the domain-level Gmail policy prevents users
from automatically forwarding incoming email to external addresses,
reducing the risk of data exfiltration.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
forwarding_enabled = gmail_client.policies.enable_auto_forwarding
if forwarding_enabled is False:
report.status = "PASS"
report.status_extended = (
f"Automatic email forwarding is disabled "
f"in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
if forwarding_enabled is None:
report.status_extended = (
f"Automatic email forwarding is not explicitly configured "
f"in domain {gmail_client.provider.identity.domain}. "
f"Auto-forwarding should be disabled to prevent data exfiltration."
)
else:
report.status_extended = (
f"Automatic email forwarding is enabled "
f"in domain {gmail_client.provider.identity.domain}. "
f"Auto-forwarding should be disabled to prevent data exfiltration."
)
findings.append(report)
return findings
@@ -0,0 +1,4 @@
from prowler.providers.common.provider import Provider
from prowler.providers.googleworkspace.services.gmail.gmail_service import Gmail
gmail_client = Gmail(Provider.get_global_provider())
@@ -0,0 +1,37 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_comprehensive_mail_storage_enabled",
"CheckTitle": "Comprehensive mail storage is enabled",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Comprehensive mail storage ensures that a copy of all sent and received messages in the domain, including messages sent or received by non-Gmail mailboxes, is stored in users' Gmail mailboxes. This makes all messages accessible to Google Vault for retention, eDiscovery, and compliance purposes.",
"Risk": "Without comprehensive mail storage, messages sent through other Google services (Calendar, Drive, etc.) may not be stored in Gmail and therefore **not subject to Vault retention policies**. This creates gaps in **compliance coverage**, **eDiscovery**, and **audit trails** that could violate regulatory requirements.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/3547347",
"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** > **Gmail**\n3. Click **Compliance** > **Comprehensive mail storage**\n4. Check **Ensure that a copy of all sent and received mail is stored in associated users' mailboxes**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable comprehensive mail storage to ensure all email is stored in Gmail mailboxes for Vault retention and eDiscovery compliance.",
"Url": "https://hub.prowler.com/check/gmail_comprehensive_mail_storage_enabled"
}
},
"Categories": [
"forensics-ready"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,49 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_comprehensive_mail_storage_enabled(Check):
"""Check that comprehensive mail storage is enabled.
This check verifies that the domain-level Gmail policy ensures a copy
of all sent and received mail is stored in users' Gmail mailboxes,
making all messages accessible to Vault for compliance and eDiscovery.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
storage_enabled = gmail_client.policies.comprehensive_mail_storage_enabled
if storage_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Comprehensive mail storage is enabled "
f"in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
if storage_enabled is None:
report.status_extended = (
f"Comprehensive mail storage is not explicitly configured "
f"in domain {gmail_client.provider.identity.domain}. "
f"Comprehensive mail storage should be enabled for compliance."
)
else:
report.status_extended = (
f"Comprehensive mail storage is disabled "
f"in domain {gmail_client.provider.identity.domain}. "
f"Comprehensive mail storage should be enabled for compliance."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_enhanced_pre_delivery_scanning_enabled",
"CheckTitle": "Enhanced pre-delivery message scanning is enabled",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Enhanced pre-delivery message scanning adds additional security checks for messages identified as potentially suspicious. When enabled, Gmail may slightly delay delivery to perform deeper analysis, improving detection of phishing and malware that might otherwise evade standard filters.",
"Risk": "Without enhanced pre-delivery scanning, some **sophisticated phishing and malware** messages may pass through standard filters and be delivered to users. The additional scanning layer catches threats that the first-pass filters miss, reducing the organization's exposure to **zero-day phishing campaigns** and **targeted attacks**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7380368",
"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** > **Gmail**\n3. Click **Spam, phishing, and malware**\n4. Check **Enhanced pre-delivery message scanning** - Enables improved detection of suspicious content prior to delivery\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable enhanced pre-delivery message scanning to improve Gmail's ability to detect and block sophisticated phishing and malware before delivery to users.",
"Url": "https://hub.prowler.com/check/gmail_enhanced_pre_delivery_scanning_enabled"
}
},
"Categories": [
"email-security"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,51 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_enhanced_pre_delivery_scanning_enabled(Check):
"""Check that enhanced pre-delivery message scanning is enabled.
This check verifies that Gmail is configured to perform additional
security checks on suspicious messages before delivering them,
improving detection of phishing and malware.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
scanning_enabled = (
gmail_client.policies.enable_enhanced_pre_delivery_scanning
)
if scanning_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Enhanced pre-delivery message scanning is enabled "
f"in domain {gmail_client.provider.identity.domain}."
)
elif scanning_enabled is None:
report.status = "PASS"
report.status_extended = (
f"Enhanced pre-delivery message scanning uses Google's "
f"secure default configuration (enabled) "
f"in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Enhanced pre-delivery message scanning is disabled "
f"in domain {gmail_client.provider.identity.domain}. "
f"Pre-delivery scanning should be enabled for improved threat detection."
)
findings.append(report)
return findings
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_external_image_scanning_enabled",
"CheckTitle": "Scanning of linked images for malicious content is enabled",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Gmail can scan images linked in email messages to detect malicious content. External images can be used to deliver tracking pixels, exploit browser vulnerabilities, or serve as part of phishing campaigns.",
"Risk": "Without external image scanning, attackers can use **linked images to track email opens**, deliver **exploit payloads via image rendering vulnerabilities**, or use images as part of sophisticated **phishing schemes** that mimic legitimate communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"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** > **Gmail**\n3. Click **Safety** > **Links and external images**\n4. Check **Scan linked images**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable scanning of linked images so that Gmail proactively checks external image resources for malicious content before displaying them to users.",
"Url": "https://hub.prowler.com/check/gmail_external_image_scanning_enabled"
}
},
"Categories": [
"email-security"
],
"DependsOn": [],
"RelatedTo": [
"gmail_shortener_scanning_enabled",
"gmail_untrusted_link_warnings_enabled"
],
"Notes": ""
}
@@ -0,0 +1,49 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_external_image_scanning_enabled(Check):
"""Check that scanning of linked images for malicious content is enabled.
This check verifies that Gmail is configured to scan images linked
in emails to detect and block malicious content hidden within
external image resources.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
scanning_enabled = gmail_client.policies.enable_external_image_scanning
if scanning_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Scanning of linked images for malicious content is enabled "
f"in domain {gmail_client.provider.identity.domain}."
)
elif scanning_enabled is None:
report.status = "PASS"
report.status_extended = (
f"Scanning of linked images for malicious content uses Google's "
f"secure default configuration (enabled) "
f"in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Scanning of linked images for malicious content is disabled "
f"in domain {gmail_client.provider.identity.domain}. "
f"External image scanning should be enabled to detect hidden threats."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_mail_delegation_disabled",
"CheckTitle": "Mail delegation is disabled for users",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Mail delegation allows a delegate to read, send, and delete messages on behalf of another user. When enabled at the user level, this creates a risk that unauthorized individuals could gain access to sensitive email content. Only administrators should be able to manage mailbox delegation.",
"Risk": "If users can delegate access to their mailbox, an attacker who compromises one account could silently delegate access to maintain persistent email surveillance. This also increases the risk of **insider threats** and **data exfiltration** through shared mailbox access.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7223765",
"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** > **Gmail**\n3. Click **User Settings** > **Mail delegation**\n4. Uncheck **Let users delegate access to their mailbox to other users in the domain**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable mail delegation so that only administrators can manage mailbox access. This prevents users from granting unauthorized access to their email.",
"Url": "https://hub.prowler.com/check/gmail_mail_delegation_disabled"
}
},
"Categories": [
"email-security"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,48 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_mail_delegation_disabled(Check):
"""Check that users cannot delegate access to their mailbox.
This check verifies that the domain-level Gmail policy prevents users
from delegating mailbox access to other users, ensuring only
administrators can manage mailbox delegation.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
delegation_enabled = gmail_client.policies.enable_mail_delegation
if delegation_enabled is False:
report.status = "PASS"
report.status_extended = (
f"Mail delegation is disabled "
f"in domain {gmail_client.provider.identity.domain}."
)
elif delegation_enabled is None:
report.status = "PASS"
report.status_extended = (
f"Mail delegation uses Google's secure default configuration "
f"(disabled) in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Mail delegation is enabled "
f"in domain {gmail_client.provider.identity.domain}. "
f"Users should not be able to delegate access to their mailbox."
)
findings.append(report)
return findings
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_per_user_outbound_gateway_disabled",
"CheckTitle": "Per-user outbound gateways are disabled",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "A per-user outbound gateway allows users to send mail through an external SMTP server instead of Google's mail servers. Disabling this setting ensures all outbound email is routed through the organization's configured mail infrastructure.",
"Risk": "With per-user outbound gateways enabled, users can route outbound email through **external SMTP servers**, bypassing organizational **email security controls**, **DLP policies**, and **audit logging**. This creates an unmonitored channel for data exfiltration and policy circumvention.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/176652",
"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** > **Gmail**\n3. Click **End User Access** > **Allow per-user outbound gateways**\n4. Uncheck **Allow users to send mail through an external SMTP server when configuring a \"from\" address hosted outside your email domain**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable per-user outbound gateways to ensure all outbound email passes through the organization's mail infrastructure where security controls and monitoring are applied.",
"Url": "https://hub.prowler.com/check/gmail_per_user_outbound_gateway_disabled"
}
},
"Categories": [
"email-security"
],
"DependsOn": [],
"RelatedTo": [
"gmail_pop_imap_access_disabled",
"gmail_auto_forwarding_disabled"
],
"Notes": ""
}
@@ -0,0 +1,49 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_per_user_outbound_gateway_disabled(Check):
"""Check that per-user outbound gateways are disabled.
This check verifies that the domain-level Gmail policy prevents users
from sending mail through external SMTP servers, ensuring all outbound
email passes through the organization's mail infrastructure.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
gateway_allowed = gmail_client.policies.allow_per_user_outbound_gateway
if gateway_allowed is False:
report.status = "PASS"
report.status_extended = (
f"Per-user outbound gateways are disabled "
f"in domain {gmail_client.provider.identity.domain}."
)
elif gateway_allowed is None:
report.status = "PASS"
report.status_extended = (
f"Per-user outbound gateways use Google's secure default "
f"configuration (disabled) "
f"in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Per-user outbound gateways are enabled "
f"in domain {gmail_client.provider.identity.domain}. "
f"External SMTP server usage should be disabled."
)
findings.append(report)
return findings
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_pop_imap_access_disabled",
"CheckTitle": "POP and IMAP access is disabled for all users",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "POP and IMAP allow users to access Gmail through legacy or third-party email clients that may not support modern authentication mechanisms such as multifactor authentication. Disabling these protocols forces users to access email through approved clients only.",
"Risk": "With POP and IMAP enabled, users can access email through **legacy clients** that rely on simple password authentication, bypassing **multifactor authentication** and other modern security controls. This significantly increases the risk of **credential-based account compromise**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/105694",
"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** > **Gmail**\n3. Click **End User Access** > **POP and IMAP Access**\n4. Uncheck **Enable IMAP access for all users**\n5. Uncheck **Enable POP access for all users**\n6. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable both POP and IMAP access to prevent users from using legacy email clients that bypass modern authentication controls.",
"Url": "https://hub.prowler.com/check/gmail_pop_imap_access_disabled"
}
},
"Categories": [
"email-security"
],
"DependsOn": [],
"RelatedTo": [
"gmail_auto_forwarding_disabled",
"gmail_per_user_outbound_gateway_disabled"
],
"Notes": ""
}
@@ -0,0 +1,67 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_pop_imap_access_disabled(Check):
"""Check that POP and IMAP access is disabled for all users.
This check verifies that the domain-level Gmail policy disables both
POP and IMAP access, preventing users from accessing email through
legacy clients that may not support modern authentication.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
pop_enabled = gmail_client.policies.enable_pop_access
imap_enabled = gmail_client.policies.enable_imap_access
if pop_enabled is False and imap_enabled is False:
report.status = "PASS"
report.status_extended = (
f"POP and IMAP access are both disabled "
f"in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
enabled_protocols = []
not_configured = []
if pop_enabled is True:
enabled_protocols.append("POP")
elif pop_enabled is None:
not_configured.append("POP")
if imap_enabled is True:
enabled_protocols.append("IMAP")
elif imap_enabled is None:
not_configured.append("IMAP")
details = []
if enabled_protocols:
details.append(
f"{' and '.join(enabled_protocols)} access is enabled"
)
if not_configured:
details.append(
f"{' and '.join(not_configured)} access is not explicitly configured"
)
report.status_extended = (
f"{'; '.join(details)} "
f"in domain {gmail_client.provider.identity.domain}. "
f"Both POP and IMAP access should be disabled to prevent use of "
f"legacy email clients."
)
findings.append(report)
return findings
@@ -0,0 +1,212 @@
from typing import Optional
from pydantic import BaseModel
from prowler.lib.logger import logger
from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService
class Gmail(GoogleWorkspaceService):
"""Google Workspace Gmail service for auditing domain-level Gmail policies.
Uses the Cloud Identity Policy API v1 to read Gmail safety, access,
delegation, and compliance settings configured in the Admin Console.
"""
def __init__(self, provider):
super().__init__(provider)
self.policies = GmailPolicies()
self.policies_fetched = False
self._fetch_gmail_policies()
def _fetch_gmail_policies(self):
"""Fetch Gmail policies from the Cloud Identity Policy API v1."""
logger.info("Gmail - Fetching Gmail 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("gmail.*")',
)
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 == "gmail.mail_delegation":
self.policies.enable_mail_delegation = value.get(
"enableMailDelegation"
)
logger.debug("Gmail mail delegation setting fetched.")
elif setting_type == "gmail.email_attachment_safety":
self.policies.encrypted_attachment_protection_consequence = value.get(
"encryptedAttachmentProtectionConsequence"
)
self.policies.script_attachment_protection_consequence = (
value.get("scriptAttachmentProtectionConsequence")
)
self.policies.anomalous_attachment_protection_consequence = value.get(
"anomalousAttachmentProtectionConsequence"
)
logger.debug("Gmail attachment safety settings fetched.")
elif setting_type == "gmail.links_and_external_images":
self.policies.enable_shortener_scanning = value.get(
"enableShortenerScanning"
)
self.policies.enable_external_image_scanning = value.get(
"enableExternalImageScanning"
)
self.policies.enable_aggressive_warnings_on_untrusted_links = value.get(
"enableAggressiveWarningsOnUntrustedLinks"
)
logger.debug(
"Gmail links and external images settings fetched."
)
elif setting_type == "gmail.spoofing_and_authentication":
self.policies.domain_spoofing_consequence = value.get(
"domainSpoofingConsequence"
)
self.policies.employee_name_spoofing_consequence = (
value.get("employeeNameSpoofingConsequence")
)
self.policies.inbound_domain_spoofing_consequence = (
value.get("inboundDomainSpoofingConsequence")
)
self.policies.unauthenticated_email_consequence = value.get(
"unauthenticatedEmailConsequence"
)
self.policies.groups_spoofing_consequence = value.get(
"groupsSpoofingConsequence"
)
logger.debug(
"Gmail spoofing and authentication settings fetched."
)
elif setting_type == "gmail.pop_access":
self.policies.enable_pop_access = value.get(
"enablePopAccess"
)
logger.debug("Gmail POP access setting fetched.")
elif setting_type == "gmail.imap_access":
self.policies.enable_imap_access = value.get(
"enableImapAccess"
)
logger.debug("Gmail IMAP access setting fetched.")
elif setting_type == "gmail.auto_forwarding":
self.policies.enable_auto_forwarding = value.get(
"enableAutoForwarding"
)
logger.debug("Gmail auto-forwarding setting fetched.")
elif setting_type == "gmail.per_user_outbound_gateway":
self.policies.allow_per_user_outbound_gateway = value.get(
"allowUsersToUseExternalSmtpServers"
)
logger.debug(
"Gmail per-user outbound gateway setting fetched."
)
elif (
setting_type
== "gmail.enhanced_pre_delivery_message_scanning"
):
self.policies.enable_enhanced_pre_delivery_scanning = (
value.get("enableImprovedSuspiciousContentDetection")
)
logger.debug(
"Gmail enhanced pre-delivery scanning setting fetched."
)
elif setting_type == "gmail.comprehensive_mail_storage":
self.policies.comprehensive_mail_storage_enabled = (
value.get("ruleId") is not None
)
logger.debug(
"Gmail comprehensive mail storage setting fetched."
)
request = service.policies().list_next(request, response)
except Exception as error:
self._handle_api_error(
error,
"fetching Gmail policies",
self.provider.identity.customer_id,
)
fetch_succeeded = False
break
self.policies_fetched = fetch_succeeded
logger.info("Gmail policies fetched successfully.")
except Exception as error:
self._handle_api_error(
error,
"fetching Gmail policies",
self.provider.identity.customer_id,
)
self.policies_fetched = False
class GmailPolicies(BaseModel):
"""Model for domain-level Gmail policy settings."""
# gmail.mail_delegation
enable_mail_delegation: Optional[bool] = None
# gmail.email_attachment_safety
encrypted_attachment_protection_consequence: Optional[str] = None
script_attachment_protection_consequence: Optional[str] = None
anomalous_attachment_protection_consequence: Optional[str] = None
# gmail.links_and_external_images
enable_shortener_scanning: Optional[bool] = None
enable_external_image_scanning: Optional[bool] = None
enable_aggressive_warnings_on_untrusted_links: Optional[bool] = None
# gmail.spoofing_and_authentication
domain_spoofing_consequence: Optional[str] = None
employee_name_spoofing_consequence: Optional[str] = None
inbound_domain_spoofing_consequence: Optional[str] = None
unauthenticated_email_consequence: Optional[str] = None
groups_spoofing_consequence: Optional[str] = None
# gmail.pop_access
enable_pop_access: Optional[bool] = None
# gmail.imap_access
enable_imap_access: Optional[bool] = None
# gmail.auto_forwarding
enable_auto_forwarding: Optional[bool] = None
# gmail.per_user_outbound_gateway
allow_per_user_outbound_gateway: Optional[bool] = None
# gmail.enhanced_pre_delivery_message_scanning
enable_enhanced_pre_delivery_scanning: Optional[bool] = None
# gmail.comprehensive_mail_storage
comprehensive_mail_storage_enabled: Optional[bool] = None
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_shortener_scanning_enabled",
"CheckTitle": "Identification of links behind shortened URLs is enabled",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Gmail can identify and expand links behind shortened URLs (e.g., bit.ly, goo.gl) to check if the destination is malicious. URL shorteners are commonly used in phishing campaigns to obscure the true destination of a link.",
"Risk": "Without shortened URL scanning, attackers can use **URL shortening services** to hide malicious destinations in phishing emails. Users cannot visually verify where the link leads, increasing the success rate of **phishing and credential harvesting** attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"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** > **Gmail**\n3. Click **Safety** > **Links and external images**\n4. Check **Identify links behind shortened URLs**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable identification of links behind shortened URLs so that Gmail can expand and scan shortened links for malicious content before users interact with them.",
"Url": "https://hub.prowler.com/check/gmail_shortener_scanning_enabled"
}
},
"Categories": [
"email-security"
],
"DependsOn": [],
"RelatedTo": [
"gmail_external_image_scanning_enabled",
"gmail_untrusted_link_warnings_enabled"
],
"Notes": ""
}
@@ -0,0 +1,49 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_shortener_scanning_enabled(Check):
"""Check that identification of links behind shortened URLs is enabled.
This check verifies that Gmail is configured to expand and scan
shortened URLs to identify potentially malicious destinations
hidden behind URL shortening services.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
scanning_enabled = gmail_client.policies.enable_shortener_scanning
if scanning_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Identification of links behind shortened URLs is enabled "
f"in domain {gmail_client.provider.identity.domain}."
)
elif scanning_enabled is None:
report.status = "PASS"
report.status_extended = (
f"Identification of links behind shortened URLs uses Google's "
f"secure default configuration (enabled) "
f"in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Identification of links behind shortened URLs is disabled "
f"in domain {gmail_client.provider.identity.domain}. "
f"Shortened URL scanning should be enabled to detect hidden malicious links."
)
findings.append(report)
return findings
@@ -0,0 +1,40 @@
{
"Provider": "googleworkspace",
"CheckID": "gmail_untrusted_link_warnings_enabled",
"CheckTitle": "Warning prompt for clicks on untrusted domain links is enabled",
"CheckType": [],
"ServiceName": "gmail",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "Gmail can display a warning prompt when users click on links to domains that are not trusted. This gives users an opportunity to reconsider before navigating to a potentially malicious website.",
"Risk": "Without untrusted link warnings, users may click on **phishing links** or links to **malware distribution sites** without any warning. This significantly increases the success rate of **social engineering attacks** targeting the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"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** > **Gmail**\n3. Click **Safety** > **Links and external images**\n4. Check **Show warning prompt for any click on links to untrusted domains**\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable warning prompts for clicks on untrusted domain links so users are alerted before navigating to potentially malicious websites from email links.",
"Url": "https://hub.prowler.com/check/gmail_untrusted_link_warnings_enabled"
}
},
"Categories": [
"email-security"
],
"DependsOn": [],
"RelatedTo": [
"gmail_shortener_scanning_enabled",
"gmail_external_image_scanning_enabled"
],
"Notes": ""
}
@@ -0,0 +1,51 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
class gmail_untrusted_link_warnings_enabled(Check):
"""Check that warning prompts for clicks on untrusted domain links are enabled.
This check verifies that Gmail is configured to show warning prompts
when users click on links to domains that are not trusted, helping
prevent users from navigating to malicious sites.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
if gmail_client.policies_fetched:
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=gmail_client.provider.domain_resource,
)
warnings_enabled = (
gmail_client.policies.enable_aggressive_warnings_on_untrusted_links
)
if warnings_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Warning prompts for clicks on untrusted domain links are enabled "
f"in domain {gmail_client.provider.identity.domain}."
)
elif warnings_enabled is None:
report.status = "PASS"
report.status_extended = (
f"Warning prompts for clicks on untrusted domain links uses Google's "
f"secure default configuration (enabled) "
f"in domain {gmail_client.provider.identity.domain}."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Warning prompts for clicks on untrusted domain links are disabled "
f"in domain {gmail_client.provider.identity.domain}. "
f"Untrusted link warnings should be enabled to protect users."
)
findings.append(report)
return findings
@@ -27,6 +27,11 @@ class admincenter_groups_not_public_visibility(Check):
"""
findings = []
for group in admincenter_client.groups.values():
# Only Microsoft 365 Groups (identified by the "Unified" group type) are in
# scope for this check per CIS M365 Foundations 1.2.1. Security,
# Distribution, and other group types are skipped.
if "Unified" not in group.group_types:
continue
report = CheckReportM365(
metadata=self.metadata(),
resource=group,
@@ -182,6 +182,7 @@ class AdminCenter(M365Service):
id=group.id,
name=getattr(group, "display_name", ""),
visibility=getattr(group, "visibility", ""),
group_types=getattr(group, "group_types", []) or [],
)
}
)
@@ -237,6 +238,7 @@ class Group(BaseModel):
id: str
name: str
visibility: Optional[str]
group_types: List[str] = []
class PasswordPolicy(BaseModel):

Some files were not shown because too many files have changed in this diff Show More