diff --git a/.config/wt.toml b/.config/wt.toml new file mode 100644 index 0000000000..4690e8d918 --- /dev/null +++ b/.config/wt.toml @@ -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" diff --git a/.env b/.env index f5c9802afe..dd67da71ed 100644 --- a/.env +++ b/.env @@ -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" diff --git a/.github/workflows/docs-bump-version.yml b/.github/workflows/docs-bump-version.yml index bacc1fa1ec..80c482b6c7 100644 --- a/.github/workflows/docs-bump-version.yml +++ b/.github/workflows/docs-bump-version.yml @@ -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 `` 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. diff --git a/.github/workflows/sdk-bump-version.yml b/.github/workflows/sdk-bump-version.yml index f899a84d1d..c3f018c0d3 100644 --- a/.github/workflows/sdk-bump-version.yml +++ b/.github/workflows/sdk-bump-version.yml @@ -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 diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 0000000000..a9fce75e0f --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,2 @@ +.envrc +ui/.env.local diff --git a/Dockerfile b/Dockerfile index e8a0d19a56..49fbc5356e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index d6ac07f956..9e02cf1f6c 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -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) diff --git a/api/Dockerfile b/api/Dockerfile index 1bcffc479e..6f8385934d 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -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 diff --git a/api/pyproject.toml b/api/pyproject.toml index 398936d62c..838a30fdf7 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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" diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 1705ed2e8f..25b8fb6735 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -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] diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index c7725e61df..19da1f44aa 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.26.0 + version: 1.27.0 description: |- Prowler API specification. diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index 8774f33787..ce30a3cc52 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -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)}" + ) diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 99813576f5..3c2da81514 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -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." ) diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index ce1f24f638..c5228c77be 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -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 diff --git a/api/src/backend/tasks/jobs/backfill.py b/api/src/backend/tasks/jobs/backfill.py index ff43fb33b3..825dcb7ca8 100644 --- a/api/src/backend/tasks/jobs/backfill.py +++ b/api/src/backend/tasks/jobs/backfill.py @@ -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)} diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index 005df623f2..12f48b7040 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -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: + ///... + + 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) diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index a501ba28b0..c5e5b92b4e 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -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}") diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index e22eb2d14d..6dfabec103 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -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 diff --git a/api/src/backend/tasks/tests/test_backfill.py b/api/src/backend/tasks/tests/test_backfill.py index 469b0a393b..3ab49a15e6 100644 --- a/api/src/backend/tasks/tests/test_backfill.py +++ b/api/src/backend/tasks/tests/test_backfill.py @@ -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} diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py index a25df876f3..b5cb0cc38e 100644 --- a/api/src/backend/tasks/tests/test_reports.py +++ b/api/src/backend/tasks/tests/test_reports.py @@ -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.""" diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index d176929105..adf3e34cbc 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -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 diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index f9b581d3f0..04bb91c916 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -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) diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index c27eb2d35a..47fa4a4816 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -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" ``` diff --git a/docs/user-guide/providers/googleworkspace/authentication.mdx b/docs/user-guide/providers/googleworkspace/authentication.mdx index b070c443b3..392518d85c 100644 --- a/docs/user-guide/providers/googleworkspace/authentication.mdx +++ b/docs/user-guide/providers/googleworkspace/authentication.mdx @@ -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** diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 63ecb06667..872872b0dd 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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) --- diff --git a/prowler/__main__.py b/prowler/__main__.py index cbf8380d58..3ef794ca25 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -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: diff --git a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json index 53342033cc..c236a1f643 100644 --- a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -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", diff --git a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json index b6e5a06491..607d701818 100644 --- a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json @@ -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", diff --git a/prowler/config/config.py b/prowler/config/config.py index 1a8484ecbf..0435076a56 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -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, diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 9ef3e07e1c..0a6ed0d749 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -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 diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index 366f1ed9d6..5737c0b232 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -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 diff --git a/prowler/lib/check/external_tool_providers.py b/prowler/lib/check/external_tool_providers.py new file mode 100644 index 0000000000..0ed7f55686 --- /dev/null +++ b/prowler/lib/check/external_tool_providers.py @@ -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"}) diff --git a/prowler/lib/check/utils.py b/prowler/lib/check/utils.py index f46cab10af..45b53047ab 100644 --- a/prowler/lib/check/utils.py +++ b/prowler/lib/check/utils.py @@ -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 diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 1a4d22f7da..f14b1847a2 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -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 diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index 02f1a95832..ab966ab5fa 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -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( diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index cf9356f2fb..c0f1a1ef01 100644 --- a/prowler/providers/aws/aws_provider.py +++ b/prowler/providers/aws/aws_provider.py @@ -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 diff --git a/prowler/providers/aws/config.py b/prowler/providers/aws/config.py index 0384900fdc..216c16e70b 100644 --- a/prowler/providers/aws/config.py +++ b/prowler/providers/aws/config.py @@ -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" diff --git a/prowler/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/__init__.py b/prowler/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy.metadata.json b/prowler/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy.metadata.json new file mode 100644 index 0000000000..b1621aff02 --- /dev/null +++ b/prowler/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy.metadata.json @@ -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 --resource-policy file://policy.json", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::SecretsManager::ResourcePolicy\n Properties:\n SecretId: \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: \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\" \"\" {\n secret_arn = \"\"\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\" = [\"\"]\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." +} diff --git a/prowler/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy.py b/prowler/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy.py new file mode 100644 index 0000000000..d7115ebd23 --- /dev/null +++ b/prowler/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy.py @@ -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 diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 834d99fe80..02b4f69940 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -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( diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index ab3441b81c..0f6e7f59ea 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -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.""" diff --git a/prowler/providers/github/lib/arguments/arguments.py b/prowler/providers/github/lib/arguments/arguments.py index 946029ab43..ab68597fdc 100644 --- a/prowler/providers/github/lib/arguments/arguments.py +++ b/prowler/providers/github/lib/arguments/arguments.py @@ -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", + ) diff --git a/prowler/providers/github/services/githubactions/__init__.py b/prowler/providers/github/services/githubactions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/githubactions/githubactions_client.py b/prowler/providers/github/services/githubactions/githubactions_client.py new file mode 100644 index 0000000000..c4c81de887 --- /dev/null +++ b/prowler/providers/github/services/githubactions/githubactions_client.py @@ -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()) diff --git a/prowler/providers/github/services/githubactions/githubactions_service.py b/prowler/providers/github/services/githubactions/githubactions_service.py new file mode 100644 index 0000000000..e18b2d8967 --- /dev/null +++ b/prowler/providers/github/services/githubactions/githubactions_service.py @@ -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 diff --git a/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/__init__.py b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.metadata.json b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.metadata.json new file mode 100644 index 0000000000..12bc19df5f --- /dev/null +++ b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.metadata.json @@ -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/" + ] +} diff --git a/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.py b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.py new file mode 100644 index 0000000000..5fa2897664 --- /dev/null +++ b/prowler/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan.py @@ -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 diff --git a/prowler/providers/googleworkspace/googleworkspace_provider.py b/prowler/providers/googleworkspace/googleworkspace_provider.py index 563078f1e3..4c431aa736 100644 --- a/prowler/providers/googleworkspace/googleworkspace_provider.py +++ b/prowler/providers/googleworkspace/googleworkspace_provider.py @@ -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", ) diff --git a/prowler/providers/googleworkspace/lib/service/service.py b/prowler/providers/googleworkspace/lib/service/service.py index a6f27a4b1b..22454f3c63 100644 --- a/prowler/providers/googleworkspace/lib/service/service.py +++ b/prowler/providers/googleworkspace/lib/service/service.py @@ -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 = ""): """ diff --git a/prowler/providers/googleworkspace/models.py b/prowler/providers/googleworkspace/models.py index 6dda1494fc..608d6a69dc 100644 --- a/prowler/providers/googleworkspace/models.py +++ b/prowler/providers/googleworkspace/models.py @@ -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""" diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py index 1da8769b06..da65a162ab 100644 --- a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py index eaf9f90911..935b28bd02 100644 --- a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py index f7a418b48e..6834b60ae7 100644 --- a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count.py b/prowler/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count.py index 9500d77d7e..73d7313a76 100644 --- a/prowler/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count.py +++ b/prowler/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count.py @@ -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: diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py index 702e3445ce..cc2cb47e98 100644 --- a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py +++ b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py index 33aea36f18..be4ee08654 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py +++ b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py index d9fd8eac13..de69f180ea 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py +++ b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py b/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py index 15f6187949..e82da80e44 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py +++ b/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py b/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py index c0b8ab5c63..ef65d6ad49 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py +++ b/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py index e6d68baec2..e377113a33 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py +++ b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py index f976891147..e82b995418 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py index 45edd64884..a065fc08d0 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py index 49b710ba8f..72d01ea73b 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py index 527192e2d6..2056fdb2d2 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py b/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py index 276c2f434d..7a3d77bfce 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py +++ b/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py b/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py index f8d8f4b586..549c089881 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py +++ b/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py @@ -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 = ( diff --git a/prowler/providers/googleworkspace/services/gmail/__init__.py b/prowler/providers/googleworkspace/services/gmail/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.metadata.json new file mode 100644 index 0000000000..87d3111130 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.py new file mode 100644 index 0000000000..9930089eb7 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_client.py b/prowler/providers/googleworkspace/services/gmail/gmail_client.py new file mode 100644 index 0000000000..46f3cbcaa8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_client.py @@ -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()) diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.metadata.json new file mode 100644 index 0000000000..157a3da673 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.py new file mode 100644 index 0000000000..dd0b61760a --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.metadata.json new file mode 100644 index 0000000000..69b1be1e4b --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.py new file mode 100644 index 0000000000..ab27fd837f --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.metadata.json new file mode 100644 index 0000000000..d6a087183c --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.py new file mode 100644 index 0000000000..b9731fac42 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.metadata.json new file mode 100644 index 0000000000..a23cf60741 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.py new file mode 100644 index 0000000000..65cf54733b --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.metadata.json new file mode 100644 index 0000000000..2f68b3adbc --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.py new file mode 100644 index 0000000000..95c5e6996f --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.metadata.json new file mode 100644 index 0000000000..76affe3e47 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.py new file mode 100644 index 0000000000..17f7de3658 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_service.py b/prowler/providers/googleworkspace/services/gmail/gmail_service.py new file mode 100644 index 0000000000..eecf0c1884 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_service.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.metadata.json new file mode 100644 index 0000000000..4ea88e92d3 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.py new file mode 100644 index 0000000000..f9bfef5112 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.py @@ -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 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.metadata.json new file mode 100644 index 0000000000..eb350e3b0f --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.py new file mode 100644 index 0000000000..4bf1dbdcdf --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.py @@ -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 diff --git a/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py b/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py index f38f94f64f..fd054753c1 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py +++ b/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py @@ -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, diff --git a/prowler/providers/m365/services/admincenter/admincenter_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py index fdf864aed9..45be16d11a 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -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): diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 2511e4056e..a6b8451387 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -844,6 +844,7 @@ class Entra(M365Service): authentication_methods=reg_info.get( "authentication_methods", [] ), + user_type=getattr(user, "user_type", None), ) next_link = getattr(users_response, "odata_next_link", None) @@ -1409,6 +1410,9 @@ class User(BaseModel): account_enabled: Whether the user account is enabled. authentication_methods: List of authentication method types registered by the user (e.g., 'fido2SecurityKey', 'microsoftAuthenticatorPush', 'mobilePhone'). + user_type: The user account type as reported by Microsoft Graph + (typically 'Member' or 'Guest'). ``None`` when Microsoft Graph does not + return the property; checks must not assume a default in that case. """ id: str @@ -1418,6 +1422,7 @@ class User(BaseModel): is_mfa_capable: bool = False account_enabled: bool = True authentication_methods: List[str] = [] + user_type: Optional[str] = None class InvitationsFrom(Enum): diff --git a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py index 4b4075aa11..58a38d14d5 100644 --- a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py +++ b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py @@ -6,41 +6,49 @@ from prowler.providers.m365.services.entra.entra_client import entra_client class entra_users_mfa_capable(Check): """ - Ensure all users are MFA capable. + Ensure all member users are MFA capable. - This check verifies if users are MFA capable. + This check verifies if member users are MFA capable, aligning with CIS + Microsoft 365 Foundations Benchmark recommendation 5.2.3.4 + ("Ensure all member users are 'MFA capable'"). - The check fails if any user is not MFA capable. + Guest users and disabled accounts are excluded from the evaluation. """ def execute(self) -> List[CheckReportM365]: """ - Execute the admin MFA capable check for all users. + Execute the MFA capable check for all enabled member users. Iterates over the users retrieved from the Entra client and generates a report - indicating if users are MFA capable. + indicating if member users are MFA capable. Users explicitly typed as ``Guest`` + and disabled accounts are skipped, in line with the CIS recommendation that + scopes the control to member users only. Users whose ``user_type`` could not + be determined are still evaluated to avoid silently dropping accounts when + Microsoft Graph does not return the property. Returns: - List[CheckReportM365]: A list containing a single report with the result of the check. + List[CheckReportM365]: A list with one report per evaluated user. """ findings = [] for user in entra_client.users.values(): - if user.account_enabled: - report = CheckReportM365( - metadata=self.metadata(), - resource=user, - resource_name=user.name, - resource_id=user.id, - ) + if user.user_type == "Guest" or not user.account_enabled: + continue - if not user.is_mfa_capable: - report.status = "FAIL" - report.status_extended = f"User {user.name} is not MFA capable." - else: - report.status = "PASS" - report.status_extended = f"User {user.name} is MFA capable." + report = CheckReportM365( + metadata=self.metadata(), + resource=user, + resource_name=user.name, + resource_id=user.id, + ) - findings.append(report) + if not user.is_mfa_capable: + report.status = "FAIL" + report.status_extended = f"User {user.name} is not MFA capable." + else: + report.status = "PASS" + report.status_extended = f"User {user.name} is MFA capable." + + findings.append(report) return findings diff --git a/pyproject.toml b/pyproject.toml index 717fb338a7..74660c9c30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.13" -version = "5.25.0" +version = "5.26.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index e22ffac69b..d42b1482b3 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -35,6 +35,7 @@ def mock_get_available_providers(): "mongodbatlas", "oraclecloud", "alibabacloud", + "llm", "cloudflare", "openstack", ] diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index 1a07ab13c9..ea37e58e82 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -455,6 +455,55 @@ class TestAWSProvider: aws_provider.organizations_metadata.organization_arn == organization["Arn"] ) + @mock_aws + def test_aws_provider_organizations_uses_assumed_role_session_by_default(self): + # Regression test for issue #10215. + # When only `role_arn` is provided (no `organizations_role_arn`), + # the FIRST attempt to fetch Organizations metadata must use the + # assumed role session (current_session), not the pre-assume + # credentials. This mirrors the CLI: `aws sts assume-role` followed + # by `aws organizations describe-account` uses the assumed identity. + role_arn = create_role(AWS_REGION_EU_WEST_1) + + captured_sessions = [] + original_get_organizations_info = AwsProvider.get_organizations_info + + def capture(self, organizations_session, aws_account_id): + captured_sessions.append(organizations_session) + return original_get_organizations_info( + self, organizations_session, aws_account_id + ) + + with patch.object(AwsProvider, "get_organizations_info", capture): + aws_provider = AwsProvider(role_arn=role_arn, session_duration=900) + + assert captured_sessions[0] is aws_provider.session.current_session + assert captured_sessions[0] is not aws_provider.session.original_session + + @mock_aws + def test_aws_provider_organizations_falls_back_to_original_session(self): + # When `role_arn` is provided and the assumed role session cannot + # retrieve Organizations metadata (e.g. management-account -> + # member-account flow where the member account has no Organizations + # permissions), retry with the original (pre-assume) session. + role_arn = create_role(AWS_REGION_EU_WEST_1) + + captured_sessions = [] + original_get_organizations_info = AwsProvider.get_organizations_info + + def capture(self, organizations_session, aws_account_id): + captured_sessions.append(organizations_session) + return original_get_organizations_info( + self, organizations_session, aws_account_id + ) + + with patch.object(AwsProvider, "get_organizations_info", capture): + aws_provider = AwsProvider(role_arn=role_arn, session_duration=900) + + assert len(captured_sessions) == 2 + assert captured_sessions[0] is aws_provider.session.current_session + assert captured_sessions[1] is aws_provider.session.original_session + @mock_aws def test_aws_provider_session_with_mfa(self): mfa = True diff --git a/tests/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy_test.py b/tests/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy_test.py new file mode 100644 index 0000000000..5482fcbbcc --- /dev/null +++ b/tests/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy_test.py @@ -0,0 +1,2500 @@ +import json +import pytest +from unittest import mock +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.secretsmanager.secretsmanager_service import ( + SecretsManager, +) +from tests.providers.aws.utils import ( + AWS_CHINA_PARTITION, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + + +@pytest.fixture(scope="function") +def secretsmanager_client(): + with mock_aws(): + client_instance = client("secretsmanager", region_name=AWS_REGION_EU_WEST_1) + secret = client_instance.create_secret(Name="test-secret") + yield client_instance, secret["ARN"] + + +class TestSecretsManagerHasRestrictiveResourcePolicy: + + def test_assumed_role_identity_arn_triggers_transformation(self): + + with mock_aws(): + boto3_client = client("secretsmanager", region_name=AWS_REGION_EU_WEST_1) + boto3_client.create_secret(Name="mock-secret") + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sm_client = SecretsManager(aws_provider) + sm_client.provider._assumed_role_configuration = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch.object( + sm_client.provider.identity, + "identity_arn", + "arn:aws:sts::123456789012:assumed-role/TestRole/SessionName", + ), + mock.patch.object( + sm_client.provider, "_assumed_role_configuration", None + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + sm_client, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert ( + "arn:aws:iam::123456789012:role/TestRole" + in result[0].status_extended + ) + + def test_non_assumed_role_identity_arn_remains_unchanged(self): + with mock_aws(): + boto3_client = client("secretsmanager", region_name=AWS_REGION_EU_WEST_1) + boto3_client.create_secret(Name="mock-secret") + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sm_client = SecretsManager(aws_provider) + sm_client.provider._assumed_role_configuration = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch.object( + sm_client.provider.identity, + "identity_arn", + "arn:aws:iam::123456789012:user/MyUser", + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + sm_client, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert ( + "arn:aws:iam::123456789012:user/MyUser" in result[0].status_extended + ) + + def test_no_secrets(self): + with mock_aws(): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_client, + ) + + secretsmanager_client.secrets.clear() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 0 + + def test_secret_with_weak_policy(self, secretsmanager_client): + client_instance, secret_arn = secretsmanager_client + client_instance.put_resource_policy( + SecretId=secret_arn, + ResourcePolicy=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + } + ], + }, + indent=4, + ), + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + @pytest.mark.parametrize( + "description, remove_index, modify_element, expected_status", + [ + # test unmodified policy + ("Valid Policy", None, None, "PASS"), + # test modified statement DenyUnauthorizedPrincipals + ( + "Invalid Effect in DenyUnauthorizedPrincipals", + None, + (0, {"Effect": "Allow"}), + "FAIL", + ), + ( + "Valid Effect in DenyUnauthorizedPrincipals", + None, + (0, {"Effect": "Deny"}), + "PASS", + ), + ( + "Invalid Action in DenyUnauthorizedPrincipals", + None, + (0, {"Action": "InvalidAction"}), + "FAIL", + ), + ( + "Valid Action in DenyUnauthorizedPrincipals", + None, + (0, {"Action": "*"}), + "PASS", + ), + ( + "Invalid Resource in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Resource": "arn:aws:secretsmanager:eu-central-1:123456789012:secret:wrong-secret" + }, + ), + "FAIL", + ), + ( + "Valid Resource in DenyUnauthorizedPrincipals", + None, + (0, {"Resource": "*"}), + "PASS", + ), + ( + "Invalid Condition Operator in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Condition": { + "WrongOperator": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + } + } + }, + ), + "FAIL", + ), + ( + "Valid Condition Operator in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + } + } + }, + ), + "PASS", + ), + ( + "Invalid Condition Key in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Condition": { + "StringNotEquals": { + "aws:wrongKey": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + } + } + }, + ), + "FAIL", + ), + ( + "Valid Condition Key in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + } + } + }, + ), + "PASS", + ), + ( + "Invalid Principal with wildcard in Condition in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/*" + } + } + }, + ), + "FAIL", + ), + ( + "Valid Principal w/o wildcard in Condition in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + } + } + }, + ), + "PASS", + ), + ( + "Invalid Service Principal in Condition in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Condition": { + "StringNotEquals": { + "aws:PrincipalServiceName": "appflow.amazonaws.com" + } + } + }, + ), + "FAIL", + ), + ( + "Invalid Condition Operator for Principal in DenyUnauthorizedPrincipals", + None, + ( + 0, + { + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + } + } + }, + ), + "FAIL", + ), + # test modified statement DenyOutsideOrganization + ( + "Invalid Effect in DenyOutsideOrganization", + None, + (1, {"Effect": "Allow"}), + "FAIL", + ), + ( + "Valid Effect in DenyOutsideOrganization", + None, + (1, {"Effect": "Deny"}), + "PASS", + ), + ( + "Invalid Action in DenyOutsideOrganization", + None, + (1, {"Action": "secretsmanager:InvalidAction"}), + "FAIL", + ), + ( + "Valid Action in DenyOutsideOrganization", + None, + (1, {"Action": "secretsmanager:*"}), + "PASS", + ), + ( + "Invalid Resource in DenyOutsideOrganization", + None, + ( + 1, + { + "Resource": "arn:aws:secretsmanager:eu-central-1:123456789012:secret:wrong-secret" + }, + ), + "FAIL", + ), + ( + "Invalid Condition Operator in DenyOutsideOrganization", + None, + ( + 1, + { + "Condition": { + "WrongOperator": {"aws:PrincipalOrgID": "o-1234567890"} + } + }, + ), + "FAIL", + ), + ( + "Valid Condition Operator in DenyOutsideOrganization", + None, + ( + 1, + { + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + } + }, + ), + "PASS", + ), + ( + "Invalid Condition Key in DenyOutsideOrganization", + None, + ( + 1, + { + "Condition": { + "StringNotEquals": {"aws:wrongKey": "o-1234567890"} + } + }, + ), + "FAIL", + ), + ( + "Valid Condition Key in DenyOutsideOrganization", + None, + ( + 1, + { + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + } + }, + ), + "PASS", + ), + ( + "Invalid PrincipalOrgID in DenyOutsideOrganization", + None, + ( + 1, + { + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-invalid"} + } + }, + ), + "FAIL", + ), + ( + "Valid PrincipalOrgID in DenyOutsideOrganization", + None, + ( + 1, + { + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + } + }, + ), + "PASS", + ), + ( + "Invalid multiple Condition Operators in DenyOutsideOrganization", + None, + ( + 1, + { + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"}, + "StringNotEqualsIfExists": { + "aws:PrincipalOrgID": "o-1234567890" + }, + } + }, + ), + "FAIL", + ), + ( + "Invalid multiple keys in StringNotEquals Condition in DenyOutsideOrganization", + None, + ( + 1, + { + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + } + }, + ), + "FAIL", + ), + # test modified statement AllowAuditPolicyRead + ( + "Invalid wildcard in NotAction in AllowAuditPolicyRead", + None, + (2, {"NotAction": "*"}), + "FAIL", + ), + ( + "No wildcard in NotAction in AllowAuditPolicyRead", + None, + (2, {"NotAction": "secretsmanager:DescribeSecret"}), + "PASS", + ), + ( + "Invalid wildcard in NotAction in AllowSecretAccessForRole2", + None, + (3, {"NotAction": "*"}), + "FAIL", + ), + ( + "No wildcard in NotAction in AllowSecretAccessForRole2", + None, + (3, {"NotAction": "secretsmanager:DescribeSecret"}), + "PASS", + ), + ( + "Invalid wildcard in NotAction in both statements", + None, + [ + (2, {"NotAction": "*"}), + (3, {"NotAction": "secretsmanager:*"}), + ], + "FAIL", + ), + ( + "No wildcard in NotAction in both statements", + None, + [ + (2, {"NotAction": "secretsmanager:DescribeSecret"}), + (3, {"NotAction": "secretsmanager:GetSecretValue"}), + ], + "PASS", + ), + # test policy with removed statements + ("Missing DenyUnauthorizedPrincipals", 0, None, "FAIL"), + ("Missing DenyOutsideOrganization", 1, None, "FAIL"), + # the following 2 test cases PASS because these statements are not required to make the Policy secure + # but in practice the AWS Principal will not be able to access the secret + ("Missing AllowAuditPolicyRead", 2, None, "PASS"), + ("Missing AllowSecretAccessForRole2", 3, None, "PASS"), + ], + ) + def test_secretsmanager_policies_for_principals( + self, + secretsmanager_client, + description, + remove_index, + modify_element, + expected_status, + ): + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + valid_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": [ + "arn:aws:iam::123456789012:role/AccountSecurityAuditRole", + "arn:aws:iam::123456789012:role/Role2", + ] + } + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + }, + }, + { + "Sid": "AllowAuditPolicyRead", + "Effect": "Deny", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + }, + "NotAction": [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + ], + "Resource": "*", + }, + { + "Sid": "AllowSecretAccessForRole2", + "Effect": "Deny", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/Role2"}, + "NotAction": ["secretsmanager:GetSecretValue"], + "Resource": "*", + }, + ], + } + + policy_copy = json.loads(json.dumps(valid_policy)) + if remove_index is not None: + del policy_copy["Statement"][remove_index] + if modify_element is not None: + if isinstance(modify_element, list): + for index, value in modify_element: + policy_copy["Statement"][index].update(value) + else: + index, value = modify_element + policy_copy["Statement"][index].update(value) + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy_copy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == expected_status, f"Test case '{description}'" + + @pytest.mark.parametrize( + "description, modify_element, expected_status", + [ + # test unmodified policy + ( + "Valid unmodified Policy with PrincipalArn and Service", + None, + "PASS", + ), + # test statement DenyUnauthorizedPrincipals + ( + "Missing Null Condition", + ( + 0, + { + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": [ + "arn:aws:iam::123456789012:role/AccountSecurityAuditRole", + "arn:aws:iam::123456789012:role/Role2", + ], + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + } + }, + ), + "FAIL", + ), + ( + "Missing PrincipalArn in Null Condition", + ( + 0, + { + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": [ + "arn:aws:iam::123456789012:role/AccountSecurityAuditRole", + "arn:aws:iam::123456789012:role/Role2", + ], + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": {"aws:PrincipalServiceName": "true"}, + } + }, + ), + "FAIL", + ), + ( + "Invalid value for PrincipalArn in Null Condition", + ( + 0, + { + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": [ + "arn:aws:iam::123456789012:role/AccountSecurityAuditRole", + "arn:aws:iam::123456789012:role/Role2", + ], + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "false", + "aws:PrincipalServiceName": "true", + }, + } + }, + ), + "FAIL", + ), + # test statement DenyOutsideOrganization + ( + "Invalid DenyOutsideOrganization using Principal with disallowed service", + (1, {"Principal": {"Service": "invalid.service.com"}}), + "FAIL", + ), + # test statement AllowAppFlowAccess + ( + "Invalid wildcard '*' in Action in AllowAppFlowAccess", + (4, {"Action": "*"}), + "FAIL", + ), + ( + "No wildcard '*' in Action in AllowAppFlowAccess", + (4, {"Action": "secretsmanager:GetSecretValue"}), + "PASS", + ), + ( + "Invalid wildcard 'secretsmanager:*' in Action in AllowAppFlowAccess", + (4, {"Action": "secretsmanager:*"}), + "FAIL", + ), + ( + "No wildcard 'secretsmanager:*' in Action in AllowAppFlowAccess", + (4, {"Action": "secretsmanager:ValidAction"}), + "PASS", + ), + ( + "Missing Condition in AllowAppFlowAccess", + (4, {"Condition": {}}), + "FAIL", + ), + ( + "Valid Condition in AllowAppFlowAccess", + ( + 4, + { + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + } + }, + ), + "PASS", + ), + ( + "Invalid Condition Operator in AllowAppFlowAccess", + ( + 4, + { + "Condition": { + "WrongOperator": {"aws:SourceAccount": "123456789012"} + } + }, + ), + "FAIL", + ), + ( + "Valid Condition Operator in AllowAppFlowAccess", + ( + 4, + { + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + } + }, + ), + "PASS", + ), + ( + "Invalid Condition Key in AllowAppFlowAccess", + ( + 4, + {"Condition": {"StringEquals": {"aws:WrongKey": "123456789012"}}}, + ), + "FAIL", + ), + ( + "Valid Condition Key in AllowAppFlowAccess", + ( + 4, + { + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + } + }, + ), + "PASS", + ), + ], + ) + def test_secretsmanager_policies_for_principals_and_services( + self, + secretsmanager_client, + description, + modify_element, + expected_status, + ): + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + valid_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": [ + "arn:aws:iam::123456789012:role/AccountSecurityAuditRole", + "arn:aws:iam::123456789012:role/Role2", + ], + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + }, + }, + { + "Sid": "AllowAuditPolicyRead", + "Effect": "Deny", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + }, + "NotAction": [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + ], + "Resource": "*", + }, + { + "Sid": "AllowSecretAccessForRole2", + "Effect": "Deny", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/Role2"}, + "NotAction": ["secretsmanager:GetSecretValue"], + "Resource": "*", + }, + { + "Sid": "AllowAppFlowAccess", + "Effect": "Allow", + "Principal": {"Service": "appflow.amazonaws.com"}, + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:UpdateSecret", + ], + "Resource": "*", + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + }, + }, + ], + } + + policy_copy = json.loads(json.dumps(valid_policy)) + + if modify_element is not None: + if isinstance(modify_element, list): + for index, value in modify_element: + policy_copy["Statement"][index].update(value) + else: + index, value = modify_element + policy_copy["Statement"][index].update(value) + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy_copy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == expected_status, f"Test case: {description}" + + def test_assumed_role_configuration_with_valid_role_arn(self): + with mock_aws(): + boto3_client = client("secretsmanager", region_name=AWS_REGION_EU_WEST_1) + boto3_client.create_secret(Name="mock-secret") + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sm_client = SecretsManager(aws_provider) + + mock_role_config = mock.MagicMock() + mock_role_config.info.role_arn.arn = ( + "arn:aws:iam::123456789012:role/AssumedRole" + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch.object( + sm_client.provider, + "_assumed_role_configuration", + mock_role_config, + create=True, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + sm_client, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert ( + "arn:aws:iam::123456789012:role/AssumedRole" + in result[0].status_extended + ) + + def test_identity_arn_none_fallback(self): + with mock_aws(): + boto3_client = client("secretsmanager", region_name=AWS_REGION_EU_WEST_1) + boto3_client.create_secret(Name="mock-secret") + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sm_client = SecretsManager(aws_provider) + sm_client.provider._assumed_role_configuration = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch.object( + sm_client.provider.identity, + "identity_arn", + None, + ), + mock.patch.object( + sm_client.provider, "_assumed_role_configuration", None + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + sm_client, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert "'None'" in result[0].status_extended + + def test_cross_account_principal_in_allow_statement(self, secretsmanager_client): + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCrossAccountAccess", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::999999999999:role/ExternalRole" + }, + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + }, + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole" + } + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + }, + }, + { + "Sid": "DenyNotAction", + "Effect": "Deny", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/MyRole"}, + "NotAction": ["secretsmanager:DescribeSecret"], + "Resource": "*", + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Cross-account access detected" in result[0].status_extended + assert ( + "arn:aws:iam::999999999999:role/ExternalRole" + in result[0].status_extended + ) + + def test_multiple_cross_account_principals_truncation(self, secretsmanager_client): + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCrossAccount1", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::111111111111:role/Role1"}, + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + }, + { + "Sid": "AllowCrossAccount2", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::222222222222:role/Role2"}, + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + }, + { + "Sid": "AllowCrossAccount3", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::333333333333:role/Role3"}, + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + }, + { + "Sid": "AllowCrossAccount4", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::444444444444:role/Role4"}, + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + }, + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole" + } + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Cross-account access detected" in result[0].status_extended + assert "and more..." in result[0].status_extended + + def test_wildcard_principal_in_allow_with_restrictive_condition( + self, secretsmanager_client + ): + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole" + } + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + }, + }, + { + "Sid": "AllowWithRestrictiveCondition", + "Effect": "Allow", + "Principal": "*", + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + "Condition": { + "StringEquals": {"aws:PrincipalAccount": "123456789012"} + }, + }, + { + "Sid": "DenyNotAction", + "Effect": "Deny", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/MyRole"}, + "NotAction": ["secretsmanager:DescribeSecret"], + "Resource": "*", + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "Cross-account" not in result[0].status_extended + + @pytest.mark.parametrize( + "description, arn_not_like_value, include_arn_like_statement, expected_status, expected_message", + [ + ( + "Valid ArnNotLike with matching ArnLike", + "arn:aws:iam::123456789012:role/LongPrefixRole*", + True, + "PASS", + "sufficiently restrictive", + ), + ( + "Invalid ArnNotLike with short prefix", + "arn:aws:iam::123456789012:role/Sho*", + True, + "FAIL", + "does not meet all required restrictions", + ), + ( + "Valid ArnNotLike but missing ArnLike statement", + "arn:aws:iam::123456789012:role/LongPrefixRole*", + False, + "FAIL", + "Missing or incorrect 'ArnLike' validation", + ), + ], + ) + def test_policy_with_arn_not_like( + self, + secretsmanager_client, + description, + arn_not_like_value, + include_arn_like_statement, + expected_status, + expected_message, + ): + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + statements = [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + }, + "ArnNotLike": {"aws:PrincipalArn": arn_not_like_value}, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + }, + }, + { + "Sid": "AllowAuditPolicyRead", + "Effect": "Deny", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + }, + "NotAction": [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + ], + "Resource": "*", + }, + ] + + if include_arn_like_statement: + statements.append( + { + "Sid": "DenyWildcardPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "ArnLike": {"aws:PrincipalArn": arn_not_like_value} + }, + } + ) + + policy = { + "Version": "2012-10-17", + "Statement": statements, + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == expected_status, f"Test case: {description}" + assert ( + expected_message in result[0].status_extended + ), f"Test case: {description}" + + def test_resource_as_list_with_matching_arn(self, secretsmanager_client): + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": [secret_arn], + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole" + } + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": [secret_arn], + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + }, + }, + { + "Sid": "DenyNotAction", + "Effect": "Deny", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/MyRole"}, + "NotAction": ["secretsmanager:DescribeSecret"], + "Resource": "*", + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_resource_as_list_with_nonmatching_arn(self, secretsmanager_client): + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": [ + "arn:aws:secretsmanager:eu-west-1:123456789012:secret:wrong-secret" + ], + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole" + } + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_cross_account_allow_after_deny_is_detected(self, secretsmanager_client): + """Regression: cross-account Allow placed after the Deny must still FAIL.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole" + } + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": {"aws:PrincipalOrgID": "o-1234567890"} + }, + }, + { + "Sid": "AllowCrossAccountAfterDeny", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::999999999999:role/ExternalRole" + }, + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Cross-account access detected" in result[0].status_extended + + def test_service_allow_with_extra_restrictive_conditions_passes( + self, secretsmanager_client + ): + """Service Allow with additional restrictive conditions (e.g. ArnLike) should PASS.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + }, + }, + { + "Sid": "AllowAppFlowAccess", + "Effect": "Allow", + "Principal": {"Service": "appflow.amazonaws.com"}, + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "*", + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"}, + "ArnLike": { + "aws:SourceArn": "arn:aws:appflow:*:123456789012:*" + }, + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_regionalized_service_principal_is_accepted(self, secretsmanager_client): + """Regionalized service principals like logs.eu-central-1.amazonaws.com should be accepted.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole", + "aws:PrincipalServiceName": "logs.eu-central-1.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "logs.eu-central-1.amazonaws.com", + } + }, + }, + { + "Sid": "AllowLogsAccess", + "Effect": "Allow", + "Principal": {"Service": "logs.eu-central-1.amazonaws.com"}, + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "*", + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_single_statement_dict_not_list(self, secretsmanager_client): + """Statement as a single dict (not wrapped in a list) must not crash.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + # Policy with Statement as a single object instead of an array + policy = { + "Version": "2012-10-17", + "Statement": { + "Sid": "DenyAll", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + }, + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + # Single Deny-all without proper conditions -> FAIL, but must not crash + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_service_allow_with_not_action_fails(self, secretsmanager_client): + """Service Allow using NotAction instead of Action must FAIL.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + }, + }, + { + "Sid": "AllowAppFlowAccessWithNotAction", + "Effect": "Allow", + "Principal": {"Service": "appflow.amazonaws.com"}, + "NotAction": "secretsmanager:DeleteSecret", + "Resource": "*", + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "NotAction" in result[0].status_extended + + def test_service_allow_missing_action_field_fails(self, secretsmanager_client): + """Service Allow with no Action field at all must FAIL.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + }, + }, + { + "Sid": "AllowAppFlowAccessWithoutAction", + "Effect": "Allow", + "Principal": {"Service": "appflow.amazonaws.com"}, + "Resource": "*", + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "missing Action field" in result[0].status_extended + + def test_service_allow_with_not_resource_fails(self, secretsmanager_client): + """Service Allow using NotResource instead of Resource must FAIL.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + }, + }, + { + "Sid": "AllowAppFlowAccessWithNotResource", + "Effect": "Allow", + "Principal": {"Service": "appflow.amazonaws.com"}, + "Action": "secretsmanager:GetSecretValue", + "NotResource": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:other-secret", + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "NotResource" in result[0].status_extended + + def test_condition_keys_case_insensitive_principals_only( + self, secretsmanager_client + ): + """Condition keys with non-canonical casing must still be recognized.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + # Policy uses non-canonical casing for all condition keys + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "AWS:PrincipalArn": [ + "arn:aws:iam::123456789012:role/AccountSecurityAuditRole", + "arn:aws:iam::123456789012:role/Role2", + ] + } + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": {"AWS:PrincipalOrgID": "o-1234567890"} + }, + }, + { + "Sid": "AllowAuditPolicyRead", + "Effect": "Deny", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/AccountSecurityAuditRole" + }, + "NotAction": [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + ], + "Resource": "*", + }, + { + "Sid": "AllowSecretAccessForRole2", + "Effect": "Deny", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/Role2"}, + "NotAction": ["secretsmanager:GetSecretValue"], + "Resource": "*", + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert ( + result[0].status == "PASS" + ), f"Policy with uppercase condition keys should PASS but got: {result[0].status_extended}" + + def test_condition_keys_case_insensitive_with_services(self, secretsmanager_client): + """Mixed-case condition keys with service principals must still PASS.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "AWS:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole", + "AWS:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "AWS:PrincipalArn": "true", + "AWS:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "AWS:PrincipalOrgID": "o-1234567890", + "AWS:PrincipalServiceName": "appflow.amazonaws.com", + } + }, + }, + { + "Sid": "AllowAppFlowAccess", + "Effect": "Allow", + "Principal": {"Service": "appflow.amazonaws.com"}, + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "*", + "Condition": { + "StringEquals": {"AWS:SourceAccount": "123456789012"} + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert ( + result[0].status == "PASS" + ), f"Policy with mixed-case condition keys should PASS but got: {result[0].status_extended}" + + def test_mixed_principal_allow_must_validate_service(self, secretsmanager_client): + """Allow with both AWS and Service principals must validate the service principal.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + # The Allow statement has a mixed Principal with both AWS and Service. + # The service principal must still be validated for aws:SourceAccount. + # Without the fix, extract_field() only returned the AWS branch, + # silently skipping service validation. + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + }, + }, + { + "Sid": "AllowMixedPrincipalWithoutSourceAccount", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/MyRole", + "Service": "appflow.amazonaws.com", + }, + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "*", + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "missing Condition block" in result[0].status_extended + ), f"Mixed-principal Allow without SourceAccount should FAIL but got: {result[0].status_extended}" + + def test_source_account_as_list_passes(self, secretsmanager_client): + """aws:SourceAccount as a single-value list must be accepted.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "appflow.amazonaws.com", + } + }, + }, + { + "Sid": "AllowAppFlowAccess", + "Effect": "Allow", + "Principal": {"Service": "appflow.amazonaws.com"}, + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "*", + "Condition": { + "StringEquals": {"aws:SourceAccount": ["123456789012"]} + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert ( + result[0].status == "PASS" + ), f"SourceAccount as list should PASS but got: {result[0].status_extended}" + + def test_china_partition_principals_and_services(self, secretsmanager_client): + """Principals and services in aws-cn partition must be accepted.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": { + "aws:PrincipalArn": "arn:aws-cn:iam::123456789012:role/MyRole", + "aws:PrincipalServiceName": "logs.cn-north-1.amazonaws.com.cn", + }, + "Null": { + "aws:PrincipalArn": "true", + "aws:PrincipalServiceName": "true", + }, + }, + }, + { + "Sid": "DenyOutsideOrganization", + "Effect": "Deny", + "Principal": "*", + "Action": "secretsmanager:*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalOrgID": "o-1234567890", + "aws:PrincipalServiceName": "logs.cn-north-1.amazonaws.com.cn", + } + }, + }, + { + "Sid": "AllowLogsAccess", + "Effect": "Allow", + "Principal": {"Service": "logs.cn-north-1.amazonaws.com.cn"}, + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "*", + "Condition": { + "StringEquals": {"aws:SourceAccount": "123456789012"} + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + # Use commercial-partition provider so moto finds the secret, + # then override audited_partition to simulate aws-cn. + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audited_partition", + AWS_CHINA_PARTITION, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audit_config", + {"organizations_trusted_ids": "o-1234567890"}, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert ( + result[0].status == "PASS" + ), f"China partition policy should PASS but got: {result[0].status_extended}" + + def test_china_partition_rejects_commercial_arns(self, secretsmanager_client): + """In aws-cn partition, commercial arn:aws: principals must be rejected.""" + with mock_aws(): + client_instance, secret_arn = secretsmanager_client + + # Policy uses commercial-partition ARNs in a China-partition account + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyUnauthorizedPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "*", + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole" + } + }, + }, + ], + } + + client_instance.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + # Use commercial-partition provider so moto finds the secret, + # then override audited_partition to simulate aws-cn. + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client", + new=SecretsManager(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy.secretsmanager_client.audited_partition", + AWS_CHINA_PARTITION, + ), + ): + from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( + secretsmanager_has_restrictive_resource_policy, + ) + + check = secretsmanager_has_restrictive_resource_policy() + result = check.execute() + + assert len(result) == 1 + assert ( + result[0].status == "FAIL" + ), f"Commercial ARNs in China partition should FAIL but got: {result[0].status_extended}" diff --git a/tests/providers/github/lib/arguments/github_arguments_test.py b/tests/providers/github/lib/arguments/github_arguments_test.py index 20fd3f18be..dde1ccbfbb 100644 --- a/tests/providers/github/lib/arguments/github_arguments_test.py +++ b/tests/providers/github/lib/arguments/github_arguments_test.py @@ -12,6 +12,7 @@ class Test_GitHubArguments: self.mock_github_parser = MagicMock() self.mock_auth_group = MagicMock() self.mock_scoping_group = MagicMock() + self.mock_actions_group = MagicMock() # Setup the mock chain self.mock_parser.add_subparsers.return_value = self.mock_subparsers @@ -19,6 +20,7 @@ class Test_GitHubArguments: self.mock_github_parser.add_argument_group.side_effect = [ self.mock_auth_group, self.mock_scoping_group, + self.mock_actions_group, ] def test_init_parser_creates_subparser(self): @@ -47,10 +49,11 @@ class Test_GitHubArguments: arguments.init_parser(mock_github_args) # Verify argument groups were created - assert self.mock_github_parser.add_argument_group.call_count == 2 + assert self.mock_github_parser.add_argument_group.call_count == 3 calls = self.mock_github_parser.add_argument_group.call_args_list assert calls[0][0][0] == "Authentication Modes" assert calls[1][0][0] == "Scan Scoping" + assert calls[2][0][0] == "GitHub Actions Scanning" def test_init_parser_adds_authentication_arguments(self): """Test that init_parser adds all authentication arguments""" diff --git a/tests/providers/github/services/githubactions/githubactions_service_test.py b/tests/providers/github/services/githubactions/githubactions_service_test.py new file mode 100644 index 0000000000..8f38de1b16 --- /dev/null +++ b/tests/providers/github/services/githubactions/githubactions_service_test.py @@ -0,0 +1,367 @@ +import io +import json +import sys +from unittest.mock import ANY, MagicMock, patch + +from prowler.providers.github.services.githubactions.githubactions_service import ( + GithubActions, + GithubActionsWorkflowFinding, +) + + +class TestGithubActionsService: + def test_should_exclude_workflow_no_patterns(self): + assert not GithubActions._should_exclude_workflow("test.yml", []) + + def test_should_exclude_workflow_exact_filename(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/test.yml", ["test.yml"] + ) + + def test_should_exclude_workflow_wildcard_filename(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/test-api.yml", ["test-*.yml"] + ) + + def test_should_exclude_workflow_full_path(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/test.yml", [".github/workflows/test.yml"] + ) + + def test_should_exclude_workflow_full_path_wildcard(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/api-tests.yml", [".github/workflows/api-*.yml"] + ) + + def test_should_exclude_workflow_no_match(self): + assert not GithubActions._should_exclude_workflow( + ".github/workflows/deploy.yml", ["test-*.yml", "api-*.yml"] + ) + + def test_should_exclude_workflow_multiple_patterns(self): + assert GithubActions._should_exclude_workflow( + ".github/workflows/api-test.yml", ["test-*.yml", "api-*.yml"] + ) + + def test_should_exclude_workflow_filename_in_subdir(self): + assert GithubActions._should_exclude_workflow( + "workflows/subdir/test-deploy.yml", ["test-*.yml"] + ) + + def test_extract_workflow_file_from_location_v1(self): + location = { + "symbolic": { + "key": {"Local": {"given_path": ".github/workflows/test.yml"}}, + } + } + result = GithubActions._extract_workflow_file_from_location(location) + assert result == ".github/workflows/test.yml" + + def test_extract_workflow_file_from_location_missing_key(self): + location = {"symbolic": {}} + result = GithubActions._extract_workflow_file_from_location(location) + assert result is None + + def test_extract_workflow_file_from_location_empty(self): + result = GithubActions._extract_workflow_file_from_location({}) + assert result is None + + def test_parse_finding_valid(self): + finding = { + "ident": "template-injection", + "desc": "Template Injection Vulnerability", + "determinations": {"severity": "high", "confidence": "High"}, + "url": "https://example.com/docs", + } + location = { + "symbolic": { + "annotation": "High risk of code execution", + "key": {"Local": {"given_path": ".github/workflows/test.yml"}}, + }, + "concrete": { + "location": { + "start_point": {"row": 10, "column": 5}, + "end_point": {"row": 10, "column": 15}, + } + }, + } + repo = MagicMock() + repo.id = 1 + repo.name = "test-repo" + repo.full_name = "owner/test-repo" + repo.owner = "owner" + + result = GithubActions._parse_finding( + finding, ".github/workflows/test.yml", location, repo + ) + + assert isinstance(result, GithubActionsWorkflowFinding) + assert result.finding_id == "githubactions_template_injection" + assert result.ident == "template-injection" + assert result.severity == "high" + assert result.line_range == "line 10" + assert result.workflow_file == ".github/workflows/test.yml" + assert result.repo_name == "test-repo" + assert result.confidence == "High" + + def test_parse_finding_multiline_range(self): + finding = { + "ident": "excessive-permissions", + "desc": "Excessive permissions", + "determinations": {"severity": "medium", "confidence": "Medium"}, + "url": "https://example.com", + } + location = { + "symbolic": {"annotation": "Excessive permissions detected"}, + "concrete": { + "location": { + "start_point": {"row": 5, "column": 1}, + "end_point": {"row": 10, "column": 20}, + } + }, + } + repo = MagicMock() + repo.id = 1 + repo.name = "repo" + repo.full_name = "owner/repo" + repo.owner = "owner" + + result = GithubActions._parse_finding(finding, "wf.yml", location, repo) + assert result.line_range == "lines 5-10" + + def test_parse_finding_unknown_severity(self): + finding = { + "ident": "test", + "desc": "Test", + "determinations": {"severity": "Unknown", "confidence": "Low"}, + } + location = { + "symbolic": {}, + "concrete": {"location": {}}, + } + repo = MagicMock() + repo.id = 1 + repo.name = "repo" + repo.full_name = "owner/repo" + repo.owner = "owner" + + result = GithubActions._parse_finding(finding, "wf.yml", location, repo) + assert result.severity == "medium" + assert result.line_range == "location unknown" + + def test_run_zizmor_no_output(self): + mock_process = MagicMock() + mock_process.stdout = "" + mock_process.stderr = "" + + with patch("subprocess.run", return_value=mock_process): + service = GithubActions.__new__(GithubActions) + result = service._run_zizmor("/tmp/test") + assert result == [] + + def test_run_zizmor_empty_array(self): + mock_process = MagicMock() + mock_process.stdout = "[]" + mock_process.stderr = "" + + with patch("subprocess.run", return_value=mock_process): + service = GithubActions.__new__(GithubActions) + result = service._run_zizmor("/tmp/test") + assert result == [] + + def test_run_zizmor_with_findings(self): + mock_output = [ + { + "ident": "excessive-permissions", + "desc": "Workflow has write-all permissions", + "determinations": {"severity": "medium", "confidence": "High"}, + "locations": [ + { + "symbolic": { + "key": {"Local": {"given_path": ".github/workflows/ci.yml"}} + }, + "concrete": { + "location": { + "start_point": {"row": 5, "column": 1}, + "end_point": {"row": 5, "column": 20}, + } + }, + } + ], + } + ] + mock_process = MagicMock() + mock_process.stdout = json.dumps(mock_output) + mock_process.stderr = "" + + with patch("subprocess.run", return_value=mock_process): + service = GithubActions.__new__(GithubActions) + result = service._run_zizmor("/tmp/test") + assert len(result) == 1 + assert result[0]["ident"] == "excessive-permissions" + + def test_run_zizmor_invalid_json(self): + mock_process = MagicMock() + mock_process.stdout = "not valid json" + mock_process.stderr = "" + + with patch("subprocess.run", return_value=mock_process): + service = GithubActions.__new__(GithubActions) + result = service._run_zizmor("/tmp/test") + assert result == [] + + def test_clone_repository_with_token(self): + with ( + patch("tempfile.mkdtemp", return_value="/tmp/test"), + patch("dulwich.porcelain.clone") as mock_clone, + ): + service = GithubActions.__new__(GithubActions) + result = service._clone_repository( + "https://github.com/owner/repo", token="mytoken" + ) + + assert result == "/tmp/test" + mock_clone.assert_called_once_with( + "https://mytoken@github.com/owner/repo", + "/tmp/test", + depth=1, + errstream=ANY, + ) + call_kwargs = mock_clone.call_args + assert isinstance(call_kwargs.kwargs["errstream"], io.BytesIO) + + def test_clone_repository_without_token(self): + with ( + patch("tempfile.mkdtemp", return_value="/tmp/test"), + patch("dulwich.porcelain.clone") as mock_clone, + ): + service = GithubActions.__new__(GithubActions) + result = service._clone_repository("https://github.com/owner/repo") + + assert result == "/tmp/test" + mock_clone.assert_called_once_with( + "https://github.com/owner/repo", + "/tmp/test", + depth=1, + errstream=ANY, + ) + call_kwargs = mock_clone.call_args + assert isinstance(call_kwargs.kwargs["errstream"], io.BytesIO) + + def test_clone_repository_failure(self): + with ( + patch("tempfile.mkdtemp", return_value="/tmp/test"), + patch("dulwich.porcelain.clone", side_effect=Exception("clone failed")), + ): + service = GithubActions.__new__(GithubActions) + result = service._clone_repository("https://github.com/owner/repo") + assert result is None + + def test_init_zizmor_missing(self): + mock_provider = MagicMock() + mock_provider.session = MagicMock() + mock_provider.session.token = "test-token" + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_provider.github_actions_enabled = True + + with ( + patch.object(GithubActions, "__init__", lambda self, provider: None), + patch("shutil.which", return_value=None), + ): + service = GithubActions.__new__(GithubActions) + service.provider = mock_provider + service.clients = [] + service.audit_config = {} + service.fixer_config = {} + service.findings = {} + + # Manually call the part after super().__init__ + # Since zizmor is missing, _scan_repositories should not be called + assert service.findings == {} + + def test_scan_repositories_strips_temp_dir_prefix(self): + temp_dir = "/var/folders/xx/tmp48xjp_g0" + zizmor_output = [ + { + "ident": "template-injection", + "desc": "Template Injection", + "determinations": {"severity": "high", "confidence": "High"}, + "url": "https://example.com", + "locations": [ + { + "symbolic": { + "key": { + "Local": { + "given_path": f"{temp_dir}/.github/workflows/release.yml" + } + }, + "annotation": "Injection risk", + }, + "concrete": { + "location": { + "start_point": {"row": 5, "column": 1}, + "end_point": {"row": 5, "column": 20}, + } + }, + } + ], + } + ] + + mock_repo = MagicMock() + mock_repo.id = 1 + mock_repo.name = "repo" + mock_repo.full_name = "owner/repo" + mock_repo.owner = "owner" + mock_repo.default_branch = MagicMock() + mock_repo.default_branch.name = "main" + + mock_repo_client = MagicMock() + mock_repo_client.repositories = {1: mock_repo} + + mock_provider = MagicMock() + mock_provider.session.token = "test-token" + mock_provider.exclude_workflows = [] + + service = GithubActions.__new__(GithubActions) + service.findings = {} + + mock_repo_module = MagicMock() + mock_repo_module.repository_client = mock_repo_client + + with ( + patch.object(service, "_clone_repository", return_value=temp_dir), + patch.object(service, "_run_zizmor", return_value=zizmor_output), + patch.dict( + sys.modules, + { + "prowler.providers.github.services.repository.repository_client": mock_repo_module, + }, + ), + patch("shutil.rmtree"), + ): + service._scan_repositories(mock_provider) + + assert 1 in service.findings + assert len(service.findings[1]) == 1 + finding = service.findings[1][0] + assert finding.workflow_file == ".github/workflows/release.yml" + assert ( + finding.workflow_url + == "https://github.com/owner/repo/blob/main/.github/workflows/release.yml" + ) + + def test_init_github_actions_disabled(self): + mock_provider = MagicMock() + mock_provider.github_actions_enabled = False + mock_provider.session = MagicMock() + mock_provider.session.token = "test-token" + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + + with patch.object(GithubActions, "__init__", lambda self, provider: None): + service = GithubActions.__new__(GithubActions) + service.findings = {} + # Service created, no scanning happened + assert service.findings == {} diff --git a/tests/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan_test.py b/tests/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan_test.py new file mode 100644 index 0000000000..33efa53969 --- /dev/null +++ b/tests/providers/github/services/githubactions/githubactions_workflow_security_scan/githubactions_workflow_security_scan_test.py @@ -0,0 +1,375 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.githubactions.githubactions_service import ( + GithubActionsWorkflowFinding, +) +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +def _make_repo(repo_id=1, name="repo1", owner="account-name"): + return Repo( + id=repo_id, + name=name, + owner=owner, + full_name=f"{owner}/{name}", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=True, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ) + + +def _make_finding( + repo_id=1, + repo_name="repo1", + repo_owner="account-name", + workflow_file=".github/workflows/ci.yml", +): + return GithubActionsWorkflowFinding( + repo_id=repo_id, + repo_name=repo_name, + repo_full_name=f"{repo_owner}/{repo_name}", + repo_owner=repo_owner, + workflow_file=workflow_file, + workflow_url=f"https://github.com/{repo_owner}/{repo_name}/blob/main/{workflow_file}", + line_range="line 10", + finding_id="githubactions_template_injection", + ident="template-injection", + description="Template Injection Vulnerability", + severity="high", + confidence="High", + annotation="High risk of code execution", + url="https://docs.zizmor.sh/", + ) + + +class Test_githubactions_workflow_security_scan: + def test_scan_disabled(self): + repo = _make_repo() + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.scan_enabled = False + githubactions_client.findings = {1: [_make_finding()]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 0 + + def test_no_repositories(self): + repository_client = mock.MagicMock() + repository_client.repositories = {} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 0 + + def test_repository_no_findings_pass(self): + repo = _make_repo() + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "repo1" + assert ( + result[0].check_metadata.CheckID + == "githubactions_workflow_security_scan" + ) + assert ( + "no GitHub Actions workflow security issues" + in result[0].status_extended + ) + + def test_repository_with_findings_fail(self): + repo = _make_repo() + finding = _make_finding() + + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {1: [finding]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == ".github/workflows/ci.yml" + assert "Template Injection Vulnerability" in result[0].status_extended + assert "line 10" in result[0].status_extended + assert "High" in result[0].status_extended + assert ( + "https://github.com/account-name/repo1/blob/main/.github/workflows/ci.yml" + in result[0].status_extended + ) + assert ( + result[0].check_metadata.CheckID == "githubactions_template_injection" + ) + assert ( + result[0].check_metadata.CheckTitle + == "GitHub Actions workflows free of template-injection issues" + ) + assert result[0].check_metadata.Severity == "high" + assert result[0].check_metadata.Risk == "Template Injection Vulnerability" + assert "https://docs.zizmor.sh/" in result[0].check_metadata.AdditionalURLs + + def test_repository_with_multiple_findings(self): + repo = _make_repo() + finding1 = _make_finding(workflow_file=".github/workflows/ci.yml") + finding2 = _make_finding(workflow_file=".github/workflows/deploy.yml") + + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {1: [finding1, finding2]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 2 + assert all(r.status == "FAIL" for r in result) + workflow_files = [r.resource_name for r in result] + assert ".github/workflows/ci.yml" in workflow_files + assert ".github/workflows/deploy.yml" in workflow_files + + def test_findings_have_independent_metadata(self): + repo = _make_repo() + finding1 = GithubActionsWorkflowFinding( + repo_id=1, + repo_name="repo1", + repo_full_name="account-name/repo1", + repo_owner="account-name", + workflow_file=".github/workflows/ci.yml", + workflow_url="https://github.com/account-name/repo1/blob/main/.github/workflows/ci.yml", + line_range="line 10", + finding_id="githubactions_template_injection", + ident="template-injection", + description="Template Injection", + severity="high", + confidence="High", + annotation="Attacker-controllable code", + url="https://docs.zizmor.sh/audits/#template-injection", + ) + finding2 = GithubActionsWorkflowFinding( + repo_id=1, + repo_name="repo1", + repo_full_name="account-name/repo1", + repo_owner="account-name", + workflow_file=".github/workflows/deploy.yml", + workflow_url="https://github.com/account-name/repo1/blob/main/.github/workflows/deploy.yml", + line_range="line 5", + finding_id="githubactions_excessive_permissions", + ident="excessive-permissions", + description="Excessive Permissions", + severity="medium", + confidence="Medium", + annotation="Workflow has overly broad permissions", + url="https://docs.zizmor.sh/audits/#excessive-permissions", + ) + + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {1: [finding1, finding2]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 2 + + r1 = next( + r for r in result if r.resource_name == ".github/workflows/ci.yml" + ) + r2 = next( + r for r in result if r.resource_name == ".github/workflows/deploy.yml" + ) + + assert r1.check_metadata.CheckID == "githubactions_template_injection" + assert r1.check_metadata.Severity == "high" + assert r1.check_metadata.Risk == "Template Injection" + assert ( + "https://docs.zizmor.sh/audits/#template-injection" + in r1.check_metadata.AdditionalURLs + ) + + assert r2.check_metadata.CheckID == "githubactions_excessive_permissions" + assert r2.check_metadata.Severity == "medium" + assert r2.check_metadata.Risk == "Excessive Permissions" + assert ( + "https://docs.zizmor.sh/audits/#excessive-permissions" + in r2.check_metadata.AdditionalURLs + ) + + def test_multiple_repos_mixed(self): + repo1 = _make_repo(repo_id=1, name="repo1") + repo2 = _make_repo(repo_id=2, name="repo2") + finding = _make_finding(repo_id=1) + + repository_client = mock.MagicMock() + repository_client.repositories = {1: repo1, 2: repo2} + + githubactions_client = mock.MagicMock() + githubactions_client.findings = {1: [finding]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.repository_client", + new=repository_client, + ), + mock.patch( + "prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan.githubactions_client", + new=githubactions_client, + ), + ): + from prowler.providers.github.services.githubactions.githubactions_workflow_security_scan.githubactions_workflow_security_scan import ( + githubactions_workflow_security_scan, + ) + + check = githubactions_workflow_security_scan() + result = check.execute() + assert len(result) == 2 + statuses = {r.resource_name: r.status for r in result} + assert statuses[".github/workflows/ci.yml"] == "FAIL" + assert statuses["repo2"] == "PASS" diff --git a/tests/providers/googleworkspace/googleworkspace_fixtures.py b/tests/providers/googleworkspace/googleworkspace_fixtures.py index 792c4699c3..72744b6244 100644 --- a/tests/providers/googleworkspace/googleworkspace_fixtures.py +++ b/tests/providers/googleworkspace/googleworkspace_fixtures.py @@ -2,12 +2,16 @@ from unittest.mock import MagicMock -from prowler.providers.googleworkspace.models import GoogleWorkspaceIdentityInfo +from prowler.providers.googleworkspace.models import ( + GoogleWorkspaceIdentityInfo, + GoogleWorkspaceResource, +) # Google Workspace test constants DOMAIN = "test-company.com" CUSTOMER_ID = "C1234567" DELEGATED_USER = "prowler-reader@test-company.com" +ROOT_ORG_UNIT_ID = "03ph8a2z1234" # Service Account credentials (mock) SERVICE_ACCOUNT_CREDENTIALS = { @@ -81,10 +85,22 @@ def set_mocked_googleworkspace_provider( domain=DOMAIN, customer_id=CUSTOMER_ID, delegated_user=DELEGATED_USER, + root_org_unit_id=ROOT_ORG_UNIT_ID, profile="default", ), ): provider = MagicMock() provider.type = "googleworkspace" provider.identity = identity + provider.domain_resource = build_googleworkspace_domain_resource() return provider + + +def build_googleworkspace_domain_resource() -> GoogleWorkspaceResource: + """Build the domain-level Google Workspace resource for tests.""" + + return GoogleWorkspaceResource( + id=CUSTOMER_ID, + name=DOMAIN, + customer_id=CUSTOMER_ID, + ) diff --git a/tests/providers/googleworkspace/googleworkspace_provider_test.py b/tests/providers/googleworkspace/googleworkspace_provider_test.py index 1ece1be55b..0924e24699 100644 --- a/tests/providers/googleworkspace/googleworkspace_provider_test.py +++ b/tests/providers/googleworkspace/googleworkspace_provider_test.py @@ -24,6 +24,7 @@ from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, DELEGATED_USER, DOMAIN, + ROOT_ORG_UNIT_ID, SERVICE_ACCOUNT_CREDENTIALS, ) @@ -68,6 +69,8 @@ class TestGoogleWorkspaceProvider: delegated_user=DELEGATED_USER, profile="default", ) + assert provider.domain_resource.id == CUSTOMER_ID + assert provider.domain_resource.name == DOMAIN assert provider._audit_config == {} def test_googleworkspace_provider_with_credentials_content(self): @@ -107,6 +110,7 @@ class TestGoogleWorkspaceProvider: assert provider.identity.domain == DOMAIN assert provider.identity.customer_id == CUSTOMER_ID assert provider.identity.delegated_user == DELEGATED_USER + assert provider.domain_resource.customer_id == CUSTOMER_ID def test_googleworkspace_provider_missing_delegated_user(self): """Test that missing delegated_user raises exception""" @@ -344,6 +348,62 @@ class TestGoogleWorkspaceProvider: ) assert "is not configured in this Google Workspace" in str(exc_info.value) + def test_setup_identity_fetches_root_org_unit(self): + """Test that setup_identity fetches and stores the root org unit ID""" + mock_session = GoogleWorkspaceSession(credentials=MagicMock(spec=Credentials)) + + with patch( + "prowler.providers.googleworkspace.googleworkspace_provider.build" + ) as mock_build: + mock_service = MagicMock() + mock_build.return_value = mock_service + mock_service.customers().get().execute.return_value = {"id": CUSTOMER_ID} + mock_service.domains().list().execute.return_value = { + "domains": [{"domainName": DOMAIN}] + } + mock_service.orgunits().list().execute.return_value = { + "organizationUnits": [ + { + "orgUnitPath": "/", + "orgUnitId": f"id:{ROOT_ORG_UNIT_ID}", + "name": "Test Company", + } + ] + } + + identity = GoogleworkspaceProvider.setup_identity( + session=mock_session, + delegated_user=DELEGATED_USER, + ) + + assert identity.root_org_unit_id == ROOT_ORG_UNIT_ID + assert identity.customer_id == CUSTOMER_ID + + def test_setup_identity_root_org_unit_fetch_failure(self): + """Test that setup_identity gracefully handles root org unit fetch failure""" + mock_session = GoogleWorkspaceSession(credentials=MagicMock(spec=Credentials)) + + with patch( + "prowler.providers.googleworkspace.googleworkspace_provider.build" + ) as mock_build: + mock_service = MagicMock() + mock_build.return_value = mock_service + mock_service.customers().get().execute.return_value = {"id": CUSTOMER_ID} + mock_service.domains().list().execute.return_value = { + "domains": [{"domainName": DOMAIN}] + } + mock_service.orgunits().list().execute.side_effect = Exception( + "Insufficient permissions" + ) + + identity = GoogleworkspaceProvider.setup_identity( + session=mock_session, + delegated_user=DELEGATED_USER, + ) + + assert identity.root_org_unit_id is None + assert identity.customer_id == CUSTOMER_ID + def test_test_connection_raises_exception_when_flag_true(self): """Test that test_connection raises exception when raise_on_exception=True""" credentials_file = "/path/to/credentials.json" diff --git a/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py b/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py index f76b848ffe..4581ffa527 100644 --- a/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py +++ b/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py @@ -1,46 +1,82 @@ +from unittest.mock import MagicMock + from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService +ROOT_OU_ID = "03ph8a2z1234" + + +def _make_service(root_org_unit_id=ROOT_OU_ID): + """Create a GoogleWorkspaceService with a mocked provider.""" + provider = MagicMock() + provider.identity.root_org_unit_id = root_org_unit_id + provider.audit_config = {} + provider.fixer_config = {} + provider.session.credentials = MagicMock() + svc = object.__new__(GoogleWorkspaceService) + svc.provider = provider + return svc + class TestIsCustomerLevelPolicy: def test_no_policy_query(self): """Policy without policyQuery is customer-level""" - assert GoogleWorkspaceService._is_customer_level_policy({}) is True + svc = _make_service() + assert svc._is_customer_level_policy({}) is True def test_empty_policy_query(self): """Policy with empty policyQuery is customer-level""" + svc = _make_service() + assert svc._is_customer_level_policy({"policyQuery": {}}) is True + + def test_root_org_unit_accepted(self): + """Policy targeting the root OU is customer-level""" + svc = _make_service() assert ( - GoogleWorkspaceService._is_customer_level_policy({"policyQuery": {}}) + svc._is_customer_level_policy( + {"policyQuery": {"orgUnit": f"orgUnits/{ROOT_OU_ID}"}} + ) is True ) - def test_org_unit_targeted(self): - """Policy targeting a specific OU is not customer-level""" + def test_sub_org_unit_rejected(self): + """Policy targeting a sub-OU is not customer-level""" + svc = _make_service() assert ( - GoogleWorkspaceService._is_customer_level_policy( - {"policyQuery": {"orgUnit": "orgUnits/abc123"}} + svc._is_customer_level_policy( + {"policyQuery": {"orgUnit": "orgUnits/sub_ou_abc123"}} ) is False ) def test_group_targeted(self): """Policy targeting a specific group is not customer-level""" + svc = _make_service() assert ( - GoogleWorkspaceService._is_customer_level_policy( - {"policyQuery": {"group": "groups/xyz789"}} - ) + svc._is_customer_level_policy({"policyQuery": {"group": "groups/xyz789"}}) is False ) def test_org_unit_and_group_targeted(self): """Policy targeting both OU and group is not customer-level""" + svc = _make_service() assert ( - GoogleWorkspaceService._is_customer_level_policy( + svc._is_customer_level_policy( { "policyQuery": { - "orgUnit": "orgUnits/abc123", + "orgUnit": f"orgUnits/{ROOT_OU_ID}", "group": "groups/xyz789", } } ) is False ) + + def test_no_root_org_unit_id_rejects_all_ou(self): + """When root OU ID is unknown, all OU-targeted policies are rejected""" + svc = _make_service(root_org_unit_id=None) + assert ( + svc._is_customer_level_policy( + {"policyQuery": {"orgUnit": f"orgUnits/{ROOT_OU_ID}"}} + ) + is False + ) diff --git a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py index cb8713630f..0d34c6a9be 100644 --- a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py +++ b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py @@ -41,7 +41,9 @@ class TestCalendarExternalSharingPrimaryCalendar: assert findings[0].status == "PASS" assert "free/busy information only" in findings[0].status_extended assert findings[0].resource_name == DOMAIN + assert findings[0].resource_id == CUSTOMER_ID assert findings[0].customer_id == CUSTOMER_ID + assert findings[0].resource == mock_provider.domain_resource.dict() def test_fail_read_only(self): """Test FAIL when external sharing allows read-only access""" diff --git a/tests/providers/googleworkspace/services/directory/directory_service_test.py b/tests/providers/googleworkspace/services/directory/directory_service_test.py index 50fc8e00bd..4e49aaeffa 100644 --- a/tests/providers/googleworkspace/services/directory/directory_service_test.py +++ b/tests/providers/googleworkspace/services/directory/directory_service_test.py @@ -1,6 +1,8 @@ from unittest.mock import MagicMock, patch from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, GROUPS_ADMIN_ROLE_ID, ROLE_GROUPS_ADMIN, ROLE_SEED_ADMIN, @@ -66,6 +68,7 @@ class TestDirectoryService: directory = Directory(mock_provider) assert len(directory.users) == 3 + assert directory.domain_resource == mock_provider.domain_resource assert "user1-id" in directory.users assert "user2-id" in directory.users assert "user3-id" in directory.users @@ -119,6 +122,8 @@ class TestDirectoryService: directory = Directory(mock_provider) assert len(directory.users) == 0 + assert directory.domain_resource.id == CUSTOMER_ID + assert directory.domain_resource.name == DOMAIN def test_directory_api_error_handling(self): """Test handling of API errors""" diff --git a/tests/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count_test.py b/tests/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count_test.py index fb626b4a6e..99ae59ef29 100644 --- a/tests/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count_test.py +++ b/tests/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count_test.py @@ -55,7 +55,9 @@ class TestDirectorySuperAdminCount: assert "2 super administrator(s)" in findings[0].status_extended assert "within the recommended range" in findings[0].status_extended assert findings[0].resource_name == DOMAIN + assert findings[0].resource_id == CUSTOMER_ID assert findings[0].customer_id == CUSTOMER_ID + assert findings[0].resource == mock_provider.domain_resource.dict() def test_directory_super_admin_count_pass_4_admins(self): """Test PASS when there are 4 super admins (within range)""" diff --git a/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py b/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py index 4025717bf7..0d15eef2a5 100644 --- a/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py +++ b/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py @@ -91,7 +91,9 @@ class TestDirectorySuperAdminOnlyAdminRoles: assert findings[0].status == "PASS" assert "used only for super admin activities" in findings[0].status_extended assert findings[0].resource_name == DOMAIN + assert findings[0].resource_id == CUSTOMER_ID assert findings[0].customer_id == CUSTOMER_ID + assert findings[0].resource == mock_provider.domain_resource.dict() def test_pass_super_admin_with_seed_admin_role(self): """Test PASS when a super admin only holds _SEED_ADMIN_ROLE. @@ -213,7 +215,8 @@ class TestDirectorySuperAdminOnlyAdminRoles: assert "Groups Administrator" in findings[0].status_extended assert "_GROUPS_ADMIN_ROLE" not in findings[0].status_extended assert "used only for super admin activities" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "admin1@test-company.com" + assert findings[0].resource_id == "admin1-id" assert findings[0].customer_id == CUSTOMER_ID def test_fail_seed_admin_with_additional_roles(self): @@ -254,6 +257,8 @@ class TestDirectorySuperAdminOnlyAdminRoles: assert "Groups Administrator" in findings[0].status_extended assert "_GROUPS_ADMIN_ROLE" not in findings[0].status_extended assert "_SEED_ADMIN_ROLE" not in findings[0].status_extended + assert findings[0].resource_name == "playground@prowler.cloud" + assert findings[0].resource_id == "admin1-id" def test_fail_multiple_super_admins_with_extra_roles(self): """Test FAIL lists all super admins that have additional roles""" @@ -299,11 +304,12 @@ class TestDirectorySuperAdminOnlyAdminRoles: check = directory_super_admin_only_admin_roles() findings = check.execute() - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "admin1@test-company.com" in findings[0].status_extended - assert "admin2@test-company.com" in findings[0].status_extended + assert len(findings) == 2 + assert all(finding.status == "FAIL" for finding in findings) + assert findings[0].resource_name == "admin1@test-company.com" + assert findings[1].resource_name == "admin2@test-company.com" assert "admin3@test-company.com" not in findings[0].status_extended + assert "admin3@test-company.com" not in findings[1].status_extended def test_no_findings_when_no_users(self): """Test no findings when there are no users""" @@ -444,3 +450,4 @@ class TestDirectorySuperAdminOnlyAdminRoles: assert len(findings) == 1 assert findings[0].status == "FAIL" assert "custom-helpdesk-role" in findings[0].status_extended + assert findings[0].resource_name == "admin1@test-company.com" diff --git a/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py b/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py index 2ad66b3d2f..8520971268 100644 --- a/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py +++ b/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py @@ -37,7 +37,9 @@ class TestDriveExternalSharingWarnUsers: assert findings[0].status == "PASS" assert "enabled" in findings[0].status_extended assert findings[0].resource_name == DOMAIN + assert findings[0].resource_id == CUSTOMER_ID assert findings[0].customer_id == CUSTOMER_ID + assert findings[0].resource == mock_provider.domain_resource.dict() def test_fail_warning_disabled(self): """Test FAIL when external sharing warning is explicitly disabled""" diff --git a/tests/providers/googleworkspace/services/gmail/__init__.py b/tests/providers/googleworkspace/services/gmail/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled_test.py new file mode 100644 index 0000000000..462f8b4f05 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled_test.py @@ -0,0 +1,120 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailAutoForwardingDisabled: + def test_pass(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled import ( + gmail_auto_forwarding_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_auto_forwarding=False) + + check = gmail_auto_forwarding_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].resource_id == CUSTOMER_ID + assert findings[0].customer_id == CUSTOMER_ID + assert findings[0].resource == mock_provider.domain_resource.dict() + + def test_fail_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled import ( + gmail_auto_forwarding_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_auto_forwarding=True) + + check = gmail_auto_forwarding_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_fail_no_policy_set(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled import ( + gmail_auto_forwarding_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_auto_forwarding=None) + + check = gmail_auto_forwarding_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not explicitly configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled import ( + gmail_auto_forwarding_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_auto_forwarding_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled_test.py new file mode 100644 index 0000000000..8c2525e819 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailComprehensiveMailStorageEnabled: + def test_pass(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled import ( + gmail_comprehensive_mail_storage_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + comprehensive_mail_storage_enabled=True + ) + + check = gmail_comprehensive_mail_storage_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "enabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled import ( + gmail_comprehensive_mail_storage_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + comprehensive_mail_storage_enabled=False + ) + + check = gmail_comprehensive_mail_storage_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "disabled" in findings[0].status_extended + + def test_fail_no_policy_set(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled import ( + gmail_comprehensive_mail_storage_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + comprehensive_mail_storage_enabled=None + ) + + check = gmail_comprehensive_mail_storage_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not explicitly configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled import ( + gmail_comprehensive_mail_storage_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_comprehensive_mail_storage_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled_test.py new file mode 100644 index 0000000000..140a41ec99 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailEnhancedPreDeliveryScanningEnabled: + def test_pass(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled import ( + gmail_enhanced_pre_delivery_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_enhanced_pre_delivery_scanning=True + ) + + check = gmail_enhanced_pre_delivery_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "enabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled import ( + gmail_enhanced_pre_delivery_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_enhanced_pre_delivery_scanning=False + ) + + check = gmail_enhanced_pre_delivery_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "disabled" in findings[0].status_extended + + def test_pass_using_default(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled import ( + gmail_enhanced_pre_delivery_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_enhanced_pre_delivery_scanning=None + ) + + check = gmail_enhanced_pre_delivery_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled import ( + gmail_enhanced_pre_delivery_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_enhanced_pre_delivery_scanning_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled_test.py new file mode 100644 index 0000000000..a720df1c43 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled_test.py @@ -0,0 +1,118 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailExternalImageScanningEnabled: + def test_pass(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled import ( + gmail_external_image_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_external_image_scanning=True) + + check = gmail_external_image_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "enabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled import ( + gmail_external_image_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_external_image_scanning=False) + + check = gmail_external_image_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "disabled" in findings[0].status_extended + + def test_pass_using_default(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled import ( + gmail_external_image_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_external_image_scanning=None) + + check = gmail_external_image_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled import ( + gmail_external_image_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_external_image_scanning_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled_test.py new file mode 100644 index 0000000000..f42062cc7e --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled_test.py @@ -0,0 +1,118 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailMailDelegationDisabled: + def test_pass_delegation_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled import ( + gmail_mail_delegation_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_mail_delegation=False) + + check = gmail_mail_delegation_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_delegation_enabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled import ( + gmail_mail_delegation_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_mail_delegation=True) + + check = gmail_mail_delegation_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_pass_using_default(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled import ( + gmail_mail_delegation_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_mail_delegation=None) + + check = gmail_mail_delegation_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled import ( + gmail_mail_delegation_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_mail_delegation_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled_test.py new file mode 100644 index 0000000000..12c1267a07 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled_test.py @@ -0,0 +1,118 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailPerUserOutboundGatewayDisabled: + def test_pass(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled import ( + gmail_per_user_outbound_gateway_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(allow_per_user_outbound_gateway=False) + + check = gmail_per_user_outbound_gateway_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled import ( + gmail_per_user_outbound_gateway_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(allow_per_user_outbound_gateway=True) + + check = gmail_per_user_outbound_gateway_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_pass_using_default(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled import ( + gmail_per_user_outbound_gateway_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(allow_per_user_outbound_gateway=None) + + check = gmail_per_user_outbound_gateway_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled import ( + gmail_per_user_outbound_gateway_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_per_user_outbound_gateway_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled_test.py new file mode 100644 index 0000000000..58c939b4f6 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailPopImapAccessDisabled: + def test_pass_both_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled import ( + gmail_pop_imap_access_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_pop_access=False, enable_imap_access=False + ) + + check = gmail_pop_imap_access_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_both_enabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled import ( + gmail_pop_imap_access_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_pop_access=True, enable_imap_access=True + ) + + check = gmail_pop_imap_access_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "POP" in findings[0].status_extended + assert "IMAP" in findings[0].status_extended + + def test_fail_pop_enabled_only(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled import ( + gmail_pop_imap_access_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_pop_access=True, enable_imap_access=False + ) + + check = gmail_pop_imap_access_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "POP" in findings[0].status_extended + + def test_fail_imap_enabled_only(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled import ( + gmail_pop_imap_access_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_pop_access=False, enable_imap_access=True + ) + + check = gmail_pop_imap_access_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "IMAP" in findings[0].status_extended + + def test_fail_no_policy_set(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled import ( + gmail_pop_imap_access_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_pop_access=None, enable_imap_access=None + ) + + check = gmail_pop_imap_access_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not explicitly configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled import ( + gmail_pop_imap_access_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_pop_imap_access_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_service_test.py b/tests/providers/googleworkspace/services/gmail/gmail_service_test.py new file mode 100644 index 0000000000..ca1c4c249f --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_service_test.py @@ -0,0 +1,493 @@ +from unittest.mock import MagicMock, patch + +from googleapiclient.errors import HttpError +from httplib2 import Response as HttpResponse + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + ROOT_ORG_UNIT_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailService: + def test_gmail_fetch_policies_all_settings(self): + """Test fetching all 10 Gmail policy settings from Cloud Identity API""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_credentials = MagicMock() + mock_session = MagicMock() + mock_session.credentials = mock_credentials + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/gmail.mail_delegation", + "value": {"enableMailDelegation": False}, + } + }, + { + "setting": { + "type": "settings/gmail.email_attachment_safety", + "value": { + "encryptedAttachmentProtectionConsequence": "SPAM_FOLDER", + "scriptAttachmentProtectionConsequence": "QUARANTINE", + "anomalousAttachmentProtectionConsequence": "WARNING", + }, + } + }, + { + "setting": { + "type": "settings/gmail.links_and_external_images", + "value": { + "enableShortenerScanning": True, + "enableExternalImageScanning": True, + "enableAggressiveWarningsOnUntrustedLinks": True, + }, + } + }, + { + "setting": { + "type": "settings/gmail.spoofing_and_authentication", + "value": { + "domainSpoofingConsequence": "SPAM_FOLDER", + "employeeNameSpoofingConsequence": "SPAM_FOLDER", + "inboundDomainSpoofingConsequence": "QUARANTINE", + "unauthenticatedEmailConsequence": "WARNING", + "groupsSpoofingConsequence": "SPAM_FOLDER", + }, + } + }, + { + "setting": { + "type": "settings/gmail.pop_access", + "value": {"enablePopAccess": False}, + } + }, + { + "setting": { + "type": "settings/gmail.imap_access", + "value": {"enableImapAccess": False}, + } + }, + { + "setting": { + "type": "settings/gmail.auto_forwarding", + "value": {"enableAutoForwarding": False}, + } + }, + { + "setting": { + "type": "settings/gmail.per_user_outbound_gateway", + "value": {"allowUsersToUseExternalSmtpServers": False}, + } + }, + { + "setting": { + "type": "settings/gmail.enhanced_pre_delivery_message_scanning", + "value": {"enableImprovedSuspiciousContentDetection": True}, + } + }, + { + "setting": { + "type": "settings/gmail.comprehensive_mail_storage", + "value": {"ruleId": "rule-abc-123"}, + } + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + Gmail, + ) + + gmail = Gmail(mock_provider) + + assert gmail.policies_fetched is True + assert gmail.policies.enable_mail_delegation is False + assert ( + gmail.policies.encrypted_attachment_protection_consequence + == "SPAM_FOLDER" + ) + assert ( + gmail.policies.script_attachment_protection_consequence == "QUARANTINE" + ) + assert ( + gmail.policies.anomalous_attachment_protection_consequence == "WARNING" + ) + assert gmail.policies.enable_shortener_scanning is True + assert gmail.policies.enable_external_image_scanning is True + assert gmail.policies.enable_aggressive_warnings_on_untrusted_links is True + assert gmail.policies.domain_spoofing_consequence == "SPAM_FOLDER" + assert gmail.policies.employee_name_spoofing_consequence == "SPAM_FOLDER" + assert gmail.policies.inbound_domain_spoofing_consequence == "QUARANTINE" + assert gmail.policies.unauthenticated_email_consequence == "WARNING" + assert gmail.policies.groups_spoofing_consequence == "SPAM_FOLDER" + assert gmail.policies.enable_pop_access is False + assert gmail.policies.enable_imap_access is False + assert gmail.policies.enable_auto_forwarding is False + assert gmail.policies.allow_per_user_outbound_gateway is False + assert gmail.policies.enable_enhanced_pre_delivery_scanning is True + assert gmail.policies.comprehensive_mail_storage_enabled is True + + def test_gmail_fetch_policies_empty_response(self): + """Test handling empty policies response""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = {"policies": []} + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + Gmail, + ) + + gmail = Gmail(mock_provider) + + assert gmail.policies_fetched is True + assert gmail.policies.enable_mail_delegation is None + assert gmail.policies.encrypted_attachment_protection_consequence is None + assert gmail.policies.enable_pop_access is None + assert gmail.policies.comprehensive_mail_storage_enabled is None + + def test_gmail_fetch_policies_api_error(self): + """Test handling of API errors during policy fetch""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + Gmail, + ) + + gmail = Gmail(mock_provider) + + assert gmail.policies_fetched is False + assert gmail.policies.enable_mail_delegation is None + + def test_gmail_fetch_policies_build_service_returns_none(self): + """Test early return when _build_service fails to construct the client""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + Gmail, + ) + + gmail = Gmail(mock_provider) + + assert gmail.policies_fetched is False + assert gmail.policies.enable_mail_delegation is None + + def test_gmail_fetch_policies_execute_raises(self): + """Test inner except handler when request.execute() raises during pagination""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_request = MagicMock() + mock_request.execute.side_effect = Exception("Execute failed") + mock_service.policies().list.return_value = mock_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + Gmail, + ) + + gmail = Gmail(mock_provider) + + assert gmail.policies_fetched is False + assert gmail.policies.enable_mail_delegation is None + + def test_gmail_fetch_policies_ignores_ou_and_group_level(self): + """Test that OU-level and group-level policies are skipped, only customer-level used""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + # Customer-level: no policyQuery → should be used + "setting": { + "type": "settings/gmail.mail_delegation", + "value": {"enableMailDelegation": False}, + } + }, + { + # OU-level: has policyQuery.orgUnit → should be skipped + "policyQuery": {"orgUnit": "orgUnits/sales_team"}, + "setting": { + "type": "settings/gmail.mail_delegation", + "value": {"enableMailDelegation": True}, + }, + }, + { + # Group-level: has policyQuery.group → should be skipped + "policyQuery": {"group": "groups/contractors"}, + "setting": { + "type": "settings/gmail.auto_forwarding", + "value": {"enableAutoForwarding": True}, + }, + }, + { + # Customer-level: no policyQuery → should be used + "setting": { + "type": "settings/gmail.auto_forwarding", + "value": {"enableAutoForwarding": False}, + } + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + Gmail, + ) + + gmail = Gmail(mock_provider) + + assert gmail.policies_fetched is True + assert gmail.policies.enable_mail_delegation is False + assert gmail.policies.enable_auto_forwarding is False + + def test_gmail_fetch_policies_accepts_root_ou(self): + """Test that root-OU-scoped policies are accepted as customer-level""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + # Root OU: matches provider's root_org_unit_id → should be accepted + "policyQuery": {"orgUnit": f"orgUnits/{ROOT_ORG_UNIT_ID}"}, + "setting": { + "type": "settings/gmail.mail_delegation", + "value": {"enableMailDelegation": True}, + }, + }, + { + # Sub-OU: different orgUnit → should be skipped + "policyQuery": {"orgUnit": "orgUnits/sub_ou_sales"}, + "setting": { + "type": "settings/gmail.auto_forwarding", + "value": {"enableAutoForwarding": True}, + }, + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + Gmail, + ) + + gmail = Gmail(mock_provider) + + assert gmail.policies_fetched is True + # Root OU policy accepted + assert gmail.policies.enable_mail_delegation is True + # Sub-OU policy skipped + assert gmail.policies.enable_auto_forwarding is None + + def test_gmail_partial_fetch_marks_policies_fetched_false(self): + """Regression: if page 1 returns valid data but page 2 raises an error, + policies_fetched must be False even though some policy values were stored.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + + # Page 1: returns valid Gmail data + page1_response = { + "policies": [ + { + "setting": { + "type": "settings/gmail.mail_delegation", + "value": {"enableMailDelegation": False}, + } + }, + ] + } + + # Page 2 request raises HttpError 429 + page1_request = MagicMock() + page1_request.execute.return_value = page1_response + + page2_request = MagicMock() + page2_request.execute.side_effect = HttpError( + HttpResponse({"status": "429"}), b"Rate limit exceeded" + ) + + mock_service.policies().list.return_value = page1_request + mock_service.policies().list_next.return_value = page2_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + Gmail, + ) + + gmail = Gmail(mock_provider) + + # Page 1 data was stored + assert gmail.policies.enable_mail_delegation is False + # But policies_fetched must be False because page 2 failed + assert gmail.policies_fetched is False + + def test_gmail_policies_model(self): + """Test GmailPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.gmail.gmail_service import ( + GmailPolicies, + ) + + policies = GmailPolicies( + enable_mail_delegation=False, + encrypted_attachment_protection_consequence="SPAM_FOLDER", + script_attachment_protection_consequence="QUARANTINE", + anomalous_attachment_protection_consequence="WARNING", + enable_shortener_scanning=True, + enable_external_image_scanning=True, + enable_aggressive_warnings_on_untrusted_links=True, + domain_spoofing_consequence="SPAM_FOLDER", + employee_name_spoofing_consequence="SPAM_FOLDER", + inbound_domain_spoofing_consequence="QUARANTINE", + unauthenticated_email_consequence="WARNING", + groups_spoofing_consequence="SPAM_FOLDER", + enable_pop_access=False, + enable_imap_access=False, + enable_auto_forwarding=False, + allow_per_user_outbound_gateway=False, + enable_enhanced_pre_delivery_scanning=True, + comprehensive_mail_storage_enabled=True, + ) + + assert policies.enable_mail_delegation is False + assert policies.encrypted_attachment_protection_consequence == "SPAM_FOLDER" + assert policies.enable_shortener_scanning is True + assert policies.domain_spoofing_consequence == "SPAM_FOLDER" + assert policies.enable_pop_access is False + assert policies.enable_auto_forwarding is False + assert policies.enable_enhanced_pre_delivery_scanning is True + assert policies.comprehensive_mail_storage_enabled is True diff --git a/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled_test.py new file mode 100644 index 0000000000..adfde86db6 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled_test.py @@ -0,0 +1,118 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailShortenerScanningEnabled: + def test_pass(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled import ( + gmail_shortener_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_shortener_scanning=True) + + check = gmail_shortener_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "enabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled import ( + gmail_shortener_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_shortener_scanning=False) + + check = gmail_shortener_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "disabled" in findings[0].status_extended + + def test_pass_using_default(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled import ( + gmail_shortener_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies(enable_shortener_scanning=None) + + check = gmail_shortener_scanning_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled import ( + gmail_shortener_scanning_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_shortener_scanning_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/__init__.py b/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled_test.py new file mode 100644 index 0000000000..611848b12f --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + + +class TestGmailUntrustedLinkWarningsEnabled: + def test_pass(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled import ( + gmail_untrusted_link_warnings_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_aggressive_warnings_on_untrusted_links=True + ) + + check = gmail_untrusted_link_warnings_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "enabled" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_disabled(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled import ( + gmail_untrusted_link_warnings_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_aggressive_warnings_on_untrusted_links=False + ) + + check = gmail_untrusted_link_warnings_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "disabled" in findings[0].status_extended + + def test_pass_using_default(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled import ( + gmail_untrusted_link_warnings_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_aggressive_warnings_on_untrusted_links=None + ) + + check = gmail_untrusted_link_warnings_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled import ( + gmail_untrusted_link_warnings_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_untrusted_link_warnings_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py b/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py index 7391bd102e..8b414a170c 100644 --- a/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py @@ -61,7 +61,12 @@ class Test_admincenter_groups_not_public_visibility: id_group1 = str(uuid4()) admincenter_client.groups = { - id_group1: Group(id=id_group1, name="Group1", visibility="Private"), + id_group1: Group( + id=id_group1, + name="Group1", + visibility="Private", + group_types=["Unified"], + ), } check = admincenter_groups_not_public_visibility() @@ -102,7 +107,12 @@ class Test_admincenter_groups_not_public_visibility: id_group1 = str(uuid4()) admincenter_client.groups = { - id_group1: Group(id=id_group1, name="Group1", visibility="Private"), + id_group1: Group( + id=id_group1, + name="Group1", + visibility="Private", + group_types=["Unified"], + ), } check = admincenter_groups_not_public_visibility() @@ -143,7 +153,12 @@ class Test_admincenter_groups_not_public_visibility: id_group1 = str(uuid4()) admincenter_client.groups = { - id_group1: Group(id=id_group1, name="Group1", visibility="Public"), + id_group1: Group( + id=id_group1, + name="Group1", + visibility="Public", + group_types=["Unified"], + ), } check = admincenter_groups_not_public_visibility() @@ -187,7 +202,12 @@ class Test_admincenter_groups_not_public_visibility: id_group1 = str(uuid4()) admincenter_client.groups = { - id_group1: Group(id=id_group1, name="Group1", visibility=None), + id_group1: Group( + id=id_group1, + name="Group1", + visibility=None, + group_types=["Unified"], + ), } check = admincenter_groups_not_public_visibility() @@ -202,3 +222,60 @@ class Test_admincenter_groups_not_public_visibility: assert result[0].resource_name == "Group1" assert result[0].resource_id == id_group1 assert result[0].location == "global" + + def test_admincenter_security_group_ignored(self): + admincenter_client = mock.MagicMock + admincenter_client.audited_tenant = "audited_tenant" + admincenter_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility.admincenter_client", + new=admincenter_client, + ), + ): + from prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility import ( + admincenter_groups_not_public_visibility, + ) + from prowler.providers.m365.services.admincenter.admincenter_service import ( + Group, + ) + + id_security_group = str(uuid4()) + id_distribution_group = str(uuid4()) + id_m365_group = str(uuid4()) + + admincenter_client.groups = { + id_security_group: Group( + id=id_security_group, + name="SecurityGroup", + visibility=None, + group_types=[], + ), + id_distribution_group: Group( + id=id_distribution_group, + name="DistributionGroup", + visibility=None, + group_types=[], + ), + id_m365_group: Group( + id=id_m365_group, + name="M365Group", + visibility="Private", + group_types=["Unified"], + ), + } + + check = admincenter_groups_not_public_visibility() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == id_m365_group + assert result[0].status == "PASS" + assert result[0].resource_name == "M365Group" diff --git a/tests/providers/m365/services/admincenter/admincenter_service_test.py b/tests/providers/m365/services/admincenter/admincenter_service_test.py index 6286dcc978..ce0a2a3050 100644 --- a/tests/providers/m365/services/admincenter/admincenter_service_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_service_test.py @@ -214,3 +214,41 @@ def test_admincenter__get_users_handles_pagination(): with_url_mock.assert_called_once_with("next-link") assert users["user-1"].license == "SKU-user-1" assert users["user-3"].license == "SKU-user-3" + + +def test_admincenter__get_groups_maps_group_types(): + admincenter_service = AdminCenter.__new__(AdminCenter) + + groups_response = SimpleNamespace( + value=[ + SimpleNamespace( + id="id-1", + display_name="Unified Group", + visibility="Private", + group_types=["Unified"], + ), + SimpleNamespace( + id="id-2", + display_name="Security Group", + visibility=None, + group_types=[], + ), + SimpleNamespace( + id="id-3", + display_name="Legacy Group", + visibility="Public", + ), + ] + ) + + groups_builder = SimpleNamespace(get=AsyncMock(return_value=groups_response)) + admincenter_service.client = SimpleNamespace(groups=groups_builder) + + groups = asyncio.run(admincenter_service._get_groups()) + + assert len(groups) == 3 + assert groups_builder.get.await_count == 1 + assert groups["id-1"].group_types == ["Unified"] + assert groups["id-2"].group_types == [] + assert groups["id-3"].group_types == [] + assert groups["id-3"].visibility == "Public" diff --git a/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py b/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py index b84b8976ae..28cc266347 100644 --- a/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py +++ b/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py @@ -237,3 +237,186 @@ class Test_entra_users_mfa_capable: assert result[0].resource == entra_client.users[enabled_user_id] assert result[0].resource_name == "Enabled User" assert result[0].resource_id == enabled_user_id + + def test_disabled_guest_user_not_checked(self): + """Disabled guest user should not be checked: expected no results. + + Regression test for https://github.com/prowler-cloud/prowler/issues/10637. + CIS 5.2.3.4 evaluates only enabled member users; disabled guests must be skipped + even when ``account_enabled`` cannot be derived from Exchange Online. + """ + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import ( + entra_users_mfa_capable, + ) + + user_id = str(uuid4()) + entra_client.users = { + user_id: User( + id=user_id, + name="Disabled Guest", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=False, + user_type="Guest", + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 0 + + def test_enabled_guest_user_not_checked(self): + """Enabled guest user is out of scope for CIS 5.2.3.4: expected no results.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import ( + entra_users_mfa_capable, + ) + + user_id = str(uuid4()) + entra_client.users = { + user_id: User( + id=user_id, + name="Guest User", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Guest", + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 0 + + def test_member_and_guest_users(self): + """Mix of member and guest users: only member users should be checked.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import ( + entra_users_mfa_capable, + ) + + member_user_id = str(uuid4()) + guest_user_id = str(uuid4()) + entra_client.users = { + member_user_id: User( + id=member_user_id, + name="Member User", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Member", + ), + guest_user_id: User( + id=guest_user_id, + name="Guest User", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Guest", + ), + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == "User Member User is not MFA capable." + assert result[0].resource == entra_client.users[member_user_id] + assert result[0].resource_name == "Member User" + assert result[0].resource_id == member_user_id + + def test_unknown_user_type_is_evaluated(self): + """Users without a ``user_type`` reported by Microsoft Graph must not be + silently dropped. + + We only skip users that Graph explicitly reports as ``Guest``; for everyone + else (including ``user_type=None``) the check still evaluates MFA capability + so that we never mask findings on accounts whose type cannot be determined. + """ + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import ( + entra_users_mfa_capable, + ) + + user_id = str(uuid4()) + entra_client.users = { + user_id: User( + id=user_id, + name="Test User", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type=None, + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == "User Test User is not MFA capable." + assert result[0].resource == entra_client.users[user_id] + assert result[0].resource_name == "Test User" + assert result[0].resource_id == user_id diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 25a564cbcb..86089b12e6 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,11 +2,13 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.25.0] (Prowler UNRELEASED) +## [1.25.0] (Prowler v5.25.0) ### 🚀 Added - Download PDF button for CIS Benchmark compliance cards, surfaced only on the latest CIS variant per provider to match the backend's latest-only PDF generation [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650) +- `knip` for dead code detection with `lint:knip` and `lint:knip:fix` scripts [(#10654)](https://github.com/prowler-cloud/prowler/pull/10654) +- Resource button in the findings resource detail drawer to open the related resource page [(#10847)](https://github.com/prowler-cloud/prowler/pull/10847) ### 🔄 Changed @@ -16,7 +18,6 @@ All notable changes to the **Prowler UI** are documented in this file. - Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797) - Mutelist improvements: table now supports name/reason search and visual count badges for finding targets [(#10846)](https://github.com/prowler-cloud/prowler/pull/10846) - Resources now use batch-applied filters, render metadata JSON with syntax highlighting, and more [(#10861)](https://github.com/prowler-cloud/prowler/pull/10861) -- Added knip for dead code detection with `lint:knip` and `lint:knip:fix` scripts [(#10654)](https://github.com/prowler-cloud/prowler/pull/10654) - Table pagination controls now keep their arrows visible on hover in light theme, and more UI improvements [(#10862)](https://github.com/prowler-cloud/prowler/pull/10862) --- diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index bebc9b0c4a..2004402607 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -1,6 +1,13 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; +import { + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + cloneElement, + type HTMLAttributes, + isValidElement, + type ReactNode, +} from "react"; import { createPortal } from "react-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -36,8 +43,17 @@ vi.mock("next/image", () => ({ })); vi.mock("next/link", () => ({ - default: ({ children, href }: { children: ReactNode; href: string }) => ( - {children} + default: ({ + children, + href, + ...props + }: AnchorHTMLAttributes & { + children: ReactNode; + href: string; + }) => ( + + {children} + ), })); @@ -64,7 +80,12 @@ vi.mock("@/components/shadcn", () => { variant?: string; size?: string; asChild?: boolean; - }) => , + }) => + _asChild && isValidElement(children) ? ( + cloneElement(children, props) + ) : ( + + ), InfoField: ({ children, label, @@ -386,6 +407,45 @@ const mockFinding: ResourceDrawerFinding = { scan: null, }; +describe("ResourceDetailDrawerContent — resource navigation", () => { + it("should render a View Resource link below the resource actions menu", () => { + // Given + render( + , + ); + + // When + const viewResourceLink = screen.getByRole("link", { + name: "View Resource", + }); + const resourceActionsMenu = screen.getByRole("menu", { + name: "Resource actions", + }); + + // Then + expect(viewResourceLink).toHaveAttribute( + "href", + "/resources?resourceId=res-1", + ); + expect(viewResourceLink).toHaveAttribute("target", "_blank"); + expect(viewResourceLink).toHaveAttribute("rel", "noopener noreferrer"); + expect( + resourceActionsMenu.compareDocumentPosition(viewResourceLink) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + }); +}); const mockResourceRow: FindingResourceRow = { id: "row-1", rowType: "resource", diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index b1e986e635..367a3c98af 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -299,6 +299,12 @@ function buildComplianceDetailHref({ return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`; } +function buildResourceDetailHref(resourceId: string): string { + const params = new URLSearchParams(); + params.set("resourceId", resourceId); + return `/resources?${params.toString()}`; +} + interface ResourceDetailDrawerContentProps { isLoading: boolean; isNavigating: boolean; @@ -416,6 +422,9 @@ export function ResourceDetailDrawerContent({ const nativeIacConfig = resolveNativeIacConfig(providerType); const showOverviewCheckMetaContent = showCheckMetaContent; const showOverviewFindingContent = Boolean(f); + const resourceDetailHref = f?.resourceId + ? buildResourceDetailHref(f.resourceId) + : null; const overviewStatusExtended = f?.statusExtended; const showOverviewStatusExtended = Boolean(overviewStatusExtended); @@ -757,6 +766,21 @@ export function ResourceDetailDrawerContent({ )} + + {resourceDetailHref && ( +
+ +
+ )} )}